Compare commits

..
Author SHA1 Message Date
Admin Bot 1dd71de97f refactor: use in-cluster authentication instead of kubeconfig secret
CI / CI (pull_request) Failing after 2m56s
RATIONALE:
Gitea CI runner is running IN-CLUSTER, so we should use Kubernetes' built-in
in-cluster authentication mechanism instead of storing kubeconfig secrets.

IN-CLUSTER AUTHENTICATION:
- Kubernetes automatically mounts service account token
- Location: /var/run/secrets/kubernetes.io/serviceaccount/token
- Location: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
- kubectl automatically detects and uses these
- No need to pass credentials via secrets

CHANGES:
1. Remove KUBECONFIG_B64 secret requirement
2. Add in-cluster auth detection step
3. Update Job to use actual built image (not golang base)
4. Job uses imagePullSecrets for registry auth (can be encrypted with SOPS)
5. Add regcred image pull secret reference

CI FLOW:
  1. Detect in-cluster authentication is available
  2. kubectl commands automatically use mounted service account
  3. No secrets needed in CI env vars
  4. Job applies with RBAC service account
  5. Registry credentials via imagePullSecrets (encrypted with SOPS)

SECURITY:
✓ In-cluster auth is more secure (bound to service account)
✓ No kubeconfig stored in secrets
✓ Sensitive data encrypted with SOPS
✓ Principle of least privilege (service account RBAC)
2026-09-13 13:51:46 +09:00
Admin Bot fa9938df8e refactor: use Kubernetes Job for integration testing instead of manual pod management
CI / CI (pull_request) Failing after 2m56s
RATIONALE:
The Kubernetes way to run integration tests is via Jobs, not manual pod management.
Jobs are simpler, more idiomatic, and handle all the complexity for us.

CHANGES:
- Remove manual: kubectl run, kubectl wait, kubectl exec
- Use Kubernetes Job (already defined in k8s/integration-test-job.yaml)
- Job handles: pod creation, retry, cleanup, status reporting
- CI only does: apply job, set image, wait, check status

SIMPLIFIED CI FLOW:
  1. go vet + go test (unit tests)
  2. Build image: api-gateway:<sha>
  3. Push: <sha> tag only
  4. Apply Job from k8s/integration-test-job.yaml
  5. Set job image to new build
  6. Wait for job completion
  7. Get logs
  8. Check job status
  9. Promote to latest (if job succeeded)
  10. Cleanup job

BENEFITS:
✅ More idiomatic (Kubernetes Job is the standard way)
✅ Simpler CI workflow (fewer manual steps)
✅ Job handles retries, backoff, cleanup automatically
✅ Better status reporting
✅ Declarative (job spec in git, not imperative in CI)
✅ Easier to test locally (just kubectl apply -f k8s/integration-test-job.yaml)

WHAT KUBERNETES JOB HANDLES:
✓ Pod creation and lifecycle
✓ Restart policy and retries
✓ Cleanup on completion
✓ Status tracking
✓ Log aggregation
✓ Resource limits
2026-09-13 13:47:02 +09:00
Admin Bot a700ac065b fix: remove kubectl installation, assume available in runner
CI / CI (pull_request) Failing after 3m6s
OPTIMIZATIONS:
- Remove curl-based kubectl installation (inefficient)
- Assume kubectl is available in Gitea runner environment
- Replace port-forward with kubectl exec for test execution
- Tests now run directly inside test pod (not from runner)
- Simpler, faster, more reliable

CI Flow:
  1. go vet + go test (unit tests)
  2. Build image: api-gateway:<sha>
  3. Push: <sha> tag only
  4. Deploy test pod with proper labels
  5. kubectl exec into pod to run tests
  6. Tests run inside pod, can reach services via network policy
  7. Promote to latest only if tests pass
  8. Cleanup test pod
2026-09-13 13:44:15 +09:00
Admin Bot e0622449cc fix: ensure test pod can reach all downstream services
CI / CI (pull_request) Failing after 2m58s
Add labels to test pod to match network policy selectors:
- app=api-gateway (matches network policy pod selector)
- managed-by=argocd (matches network policy pod selector)
- role=test (identify as test pod)
- test-run=<sha> (track which test run spawned it)

Network policy 'api-gateway' in api namespace already allows egress to:
✅ kube-system (DNS resolution)
✅ poimen (port 8080 - Memory service)
✅ temporal (port 7233 - Workflow service)
✅ storage (ports 80, 9000 - S3/MinIO)
✅ sqs (port 9090 - SQS service)
✅ iam (ports 9000, 9443 - Authentik/IAM)

Test pod inherits same network access as production pods via labels.
No additional network policies needed.
2026-09-13 11:48:04 +09:00
Admin Bot 52c36e587b feat: proper CI/CD workflow with integration testing
BREAKING CHANGE: CI now requires kubeconfig to run integration tests

Changes:
- Build image with commit SHA tag (NOT latest yet)
- Deploy dedicated test pod from new image
- Run full integration test suite against test pod
- Only promote to latest tag AFTER tests pass
- Cleanup test pod after run

CI/CD Flow:
  1. go vet + go test (unit tests)
  2. Build image: api-gateway:<sha>
  3. Push to registry
  4. Deploy test pod with <sha> image
  5. Run integration tests (memory, S3, SQS, workflow, IAM, health)
  6. If tests pass: tag as latest and push
  7. If tests fail: keep <sha> tag, don't promote to latest
  8. Cleanup test pod

This ensures:
- New code is tested in cluster before production deployment
- ArgoCD only pulls latest after tests pass
- Failed builds don't get promoted to production
- Full test coverage of all adapters

Requires: KUBECONFIG_B64 secret in Gitea for cluster access
2026-09-13 11:46:46 +09:00
67 changed files with 1566 additions and 10482 deletions
+65 -128
View File
@@ -17,10 +17,10 @@ jobs:
name: CI
runs-on: golang
steps:
- name: Install dependencies
- name: Install Node.js and Docker
run: |
apt-get update
apt-get install -y docker.io curl nodejs
apt-get install -y nodejs docker.io
- name: Checkout code
uses: actions/checkout@v4
@@ -29,14 +29,13 @@ jobs:
run: go vet ./...
- name: Go test
run: go test -short ./...
run: go test ./...
- name: Get short SHA
id: sha
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Registry login
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \
--username "${REGISTRY_USER}" --password-stdin
@@ -45,145 +44,83 @@ jobs:
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
- name: Build Docker image
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
docker build --no-cache \
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
-f Dockerfile .
echo "Built image: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
- name: Push image (SHA tag)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
# ── Deploy canary pod + integration test ────────────────
# Canary pod runs in-cluster with real secrets and real upstreams
# (Temporal, Gotify). Tests run against its pod IP directly.
# Zero downtime: production pods untouched until tests pass.
- name: Setup kubeconfig
if: false # SKIP: only needed for canary deploy, RBAC not ready
- name: Push test image (SHA tag only, not latest yet)
run: |
mkdir -p ~/.kube
echo "${KUBECONFIG_B64}" | base64 -d > ~/.kube/config
kubectl get namespaces --no-headers | head -1
echo '✓ kubeconfig works'
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
echo "✓ Pushed test image: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
- name: Deploy canary pod
if: false # SKIP: SA lacks pod create RBAC. Re-enable when fixed.
id: canary
- name: Detect in-cluster Kubernetes authentication
run: |
SHA="${{ steps.sha.outputs.short_sha }}"
POD_NAME="api-gateway-canary-${SHA}"
# Remove any stale canary
kubectl delete pod "${POD_NAME}" -n api --ignore-not-found
# Create canary pod with real secrets + config
kubectl run "${POD_NAME}" \
--image="${IMAGE}:${SHA}" \
--restart=Never \
--namespace=api \
--labels="app=api-gateway-canary,sha=${SHA}" \
--overrides="$(cat <<JSON
{
"spec": {
"imagePullSecrets": [{"name": "forgejo-registry"}],
"volumes": [
{"name": "config", "secret": {"secretName": "api-gateway-config"}}
],
"containers": [{
"name": "${POD_NAME}",
"image": "${IMAGE}:${SHA}",
"imagePullPolicy": "Always",
"env": [
{"name": "LISTEN_ADDR", "value": "0.0.0.0:8080"},
{"name": "CONFIG_PATH", "value": "/etc/gateway/config.yaml"},
{"name": "LOG_LEVEL", "value": "info"},
{"name": "GOTIFY_URL", "valueFrom": {"secretKeyRef": {"name": "gotify-webhook-secret", "key": "gotify-url", "optional": true}}},
{"name": "GOTIFY_APP_TOKEN", "valueFrom": {"secretKeyRef": {"name": "gotify-webhook-secret", "key": "gotify-app-token", "optional": true}}},
{"name": "FORGEJO_WEBHOOK_SECRET","valueFrom": {"secretKeyRef": {"name": "gotify-webhook-secret", "key": "forgejo-webhook-secret", "optional": true}}},
{"name": "AUTH_CLIENT_SECRET", "valueFrom": {"secretKeyRef": {"name": "api-gw-client-secret", "key": "client-secret", "optional": true}}}
],
"volumeMounts": [
{"name": "config", "mountPath": "/etc/gateway", "readOnly": true}
],
"readinessProbe": {
"httpGet": {"path": "/healthz", "port": 8080},
"initialDelaySeconds": 3,
"periodSeconds": 2
}
}]
}
}
JSON
)"
echo "✓ Canary pod created: ${POD_NAME}"
# Wait for pod to be ready (real secrets, real upstreams)
kubectl wait pod/"${POD_NAME}" -n api \
--for=condition=Ready --timeout=90s
# Capture pod IP for test runner
POD_IP=$(kubectl get pod "${POD_NAME}" -n api \
-o jsonpath='{.status.podIP}')
echo "pod_name=${POD_NAME}" >> $GITHUB_OUTPUT
echo "pod_ip=${POD_IP}" >> $GITHUB_OUTPUT
echo "✓ Canary ready at ${POD_IP}:8080"
- name: Run integration tests against canary
if: false # SKIP: depends on canary pod
id: tests
run: |
POD_IP="${{ steps.canary.outputs.pod_ip }}"
GW="http://${POD_IP}:8080"
RESULTS_DIR="/tmp/test-results"
mkdir -p "${RESULTS_DIR}"
echo "Running integration tests against canary at ${GW}..."
sh k8s/tekton/scripts/integration-test.sh
RESULT=$(cat "${RESULTS_DIR}/result" 2>/dev/null || echo "fail")
SUMMARY=$(cat "${RESULTS_DIR}/summary" 2>/dev/null || echo "unknown")
echo "Result: ${RESULT}"
echo "Summary: ${SUMMARY}"
echo "result=${RESULT}" >> $GITHUB_OUTPUT
- name: Cleanup canary pod
if: false # SKIP: depends on canary pod
run: |
POD_NAME="${{ steps.canary.outputs.pod_name }}"
if [ -n "${POD_NAME}" ]; then
kubectl delete pod "${POD_NAME}" -n api --ignore-not-found || true
echo "✓ Canary pod removed"
# When running inside K8s cluster, kubectl auto-detects service account
# Mounted at: /var/run/secrets/kubernetes.io/serviceaccount/
if [ -f /var/run/secrets/kubernetes.io/serviceaccount/token ]; then
echo "✓ In-cluster authentication detected"
export KUBECONFIG=/dev/null # kubectl will auto-use in-cluster auth
else
echo "⚠ No canary pod to clean up (name was empty)"
echo "⚠ Not running in-cluster, kubectl may fail"
fi
- name: Gate on test result
if: false # SKIP: depends on canary pod
- name: Run integration tests via Kubernetes Job
run: |
echo "✗ Integration tests FAILED — production deployment unchanged"
exit 1
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
fi
continue-on-error: false
# ── Zero-downtime promotion ──────────────────────────────
# kubectl set image triggers a rolling update: new pods come up
# before old pods are terminated. Production traffic uninterrupted.
# TODO: Re-enable rolling update when kubeconfig/RBAC is ready
# - name: Promote — rolling update
# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# run: |
# kubectl set image deployment/api-gateway api-gateway="${IMAGE}:${SHA}" -n api
# kubectl rollout status deployment/api-gateway -n api --timeout=120s
- name: Tag :latest (informational)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- name: Promote image to latest (only if tests passed)
if: success()
run: |
docker pull "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
docker tag "${IMAGE}:${{ steps.sha.outputs.short_sha }}" "${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()
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
+17 -103
View File
@@ -571,128 +571,42 @@ curl -X GET https://api.riotpiao.com/ \
## Authentication
All operations except `/healthz` and `/readyz` require JWT authentication.
### Bearer Token (JWT)
Provide JWT in Authorization header:
All operations except `/healthz` and `/readyz` require authentication.
```bash
curl -H 'Authorization: Bearer <jwt-token>' \
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
#### User Login (OIDC Device Code Flow)
**Via Authentik OIDC (human login):**
```bash
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
```
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)
JWT claims contain permission arrays. Required capabilities:
Tokens embed capabilities in claims. Required capabilities:
| Capability | Used For |
|------------|----------|
| `llm:inference` | `/v1/chat/completions`, `/v1/embeddings`, `/v1/rerank` |
| `workflow:execute` | `/workflow` (Temporal operations) |
| `memory:read` | `/memory` query operations |
| `memory:write` | `/memory` ingest operations |
| `sqs:access` | `/sqs` queue operations |
| `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).
- `llm:inference` — `/v1/*` chat/embeddings/rerank
- `workflow:execute` — `/workflow` operations
- `memory:read` — Memory queries
- `memory:write` — Memory ingest
- `sqs:access` — Queue operations
- `s3:access` — S3 operations
- `iam:admin` — IAM management
---
+29 -28
View File
@@ -38,22 +38,22 @@ Production API gateway for the homelab cluster. Single entry point (`api.riotpia
│ (routing, auth, limits) │
└──────┬───────────────────────┘
│
┌──────┴───────────────────────────────────┐
│ │
/v1/* X-Service header routing /
(LLM) (workflow, sqs, s3, iam, memory) /
│ │ │
▼ ▼ ▼
llm-serving temporal:7233 kmsvc/Kafka, MinIO,
(vLLM, Ollama) (gRPC) Authentik, poimen-memory
(TEI)
┌──────┴──────────────────────────────────┐
│ │
/v1/* /workflow /sqs /
(LLM) (Temporal gRPC) (Queues) (X-Service)
│ │ │ │
▼ ▼ ▼ ▼
llm-serving temporal:7233 kmsvc/Kafka IAM, S3
(vLLM, Ollama) (WorkflowService) Memory
(TEI) (gRPC bridge) (poimen)
```
**Design principles:**
- ✅ Single hostname, unified X-Service + X-Resource header routing
- ✅ HTTP REST gateway → gRPC Temporal bridge (via X-Service: workflow)
- ✅ Single hostname, multiple path prefixes
- ✅ HTTP REST gateway → gRPC Temporal bridge
- ✅ Bearer token auth via Authentik (JWT + RBAC)
- ✅ Streaming unbuffered (SSE, WebSocket, HTTP/2 multiplexing)
- ✅ Streaming unbuffered (SSE, WebSocket)
- ✅ Per-route timeouts & rate limits
- ✅ No cluster credentials held by gateway
@@ -61,16 +61,16 @@ llm-serving temporal:7233 kmsvc/Kafka, MinIO,
## Services & Capabilities
| Service | Method | Upstream | Status |
| Service | Prefix | Upstream | Status |
|---------|--------|----------|--------|
| **LLM Chat** | `POST /v1/chat/completions` | llm-serving (vLLM) | ✅ Live |
| **Embeddings** | `POST /v1/embeddings` | llm-serving (TEI) | ✅ Live |
| **Reranking** | `POST /v1/rerank` | llm-serving (TEI) | ✅ Live |
| **Workflows** | `X-Service: workflow` + `X-Resource: {action}` | Temporal gRPC (7233) | ✅ Live (START, DESCRIBE, SIGNAL, QUERY, etc) |
| **Queues** | `X-Service: sqs` + `X-Resource: {action}` | kmsvc/Kafka | ✅ Live |
| **Memory** | `X-Service: memory` + `X-Resource: {action}` | poimen-memory | ✅ Live |
| **IAM** | `X-Service: iam` + `X-Resource: {action}` | Authentik API | ✅ Live |
| **S3** | `X-Service: s3` + `X-Resource: {action}` | MinIO | ✅ Live |
| **LLM Chat** | `/v1/chat/completions` | llm-serving (vLLM) | ✅ Live |
| **Embeddings** | `/v1/embeddings` | llm-serving (TEI) | ✅ Live |
| **Reranking** | `/v1/rerank` | llm-serving (TEI) | ✅ Live |
| **Workflows** | `/workflow` | Temporal gRPC (7233) | ✅ Live (START, DESCRIBE, SIGNAL, QUERY, etc) |
| **Queues** | `/` + `X-Service: sqs` | kmsvc/Kafka | ⏳ Ready (ServiceAdapter) |
| **Memory** | `/` + `X-Service: memory` | poimen-memory | ✅ Live |
| **IAM** | `/` + `X-Service: iam` | Authentik API | ✅ Live |
| **S3** | `/` + `X-Service: s3` | MinIO | ✅ Live |
---
@@ -102,17 +102,18 @@ curl -X POST https://api.riotpiao.com/v1/chat/completions \
}'
```
**Workflow (via X-Service header):**
**Workflow:**
```bash
curl -X POST https://api.riotpiao.com/ \
curl -X POST https://api.riotpiao.com/workflow \
-H "Authorization: Bearer $TOKEN" \
-H "X-Service: workflow" \
-H "X-Resource: start" \
-d '{
"action": "START_WORKFLOW",
"namespace": "default",
"workflow_id": "my-workflow",
"workflow_type": "MyWorkflow",
"task_queue": "default"
"payload": {
"workflow_id": "my-workflow",
"workflow_type": "MyWorkflow",
"task_queue": "default"
}
}'
```
-58
View File
@@ -11,8 +11,6 @@ import (
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"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/server"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
@@ -74,59 +72,7 @@ func main() {
// Create ServiceAdapter registry and dispatcher (phase 8)
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 {
if existing := registry.Get(a.ServiceName); existing != nil {
log.Printf("skip config adapter '%s': already registered with internal handler", a.ServiceName)
continue
}
_ = registry.Add(a)
}
log.Printf("%d service adapters loaded", registry.Count())
@@ -137,14 +83,10 @@ func main() {
jwtValidator = auth.NewValidator(cfg.Auth.Issuer, cfg.Auth.Audience, cfg.Auth.JWKSURL)
}
dispatcher := serviceadapter.NewDispatcher(registry, jwtValidator)
// Wire workflow adapter to temporal handler for proper request forwarding
// workflowAdapterHandler (above) handles JSON-to-gRPC translation for workflow service
// Create router that handles health endpoints, X-Service (ServiceAdapter) routing,
// temporal endpoints, and passes others to upstream handler
router := server.NewRouter(healthChecker, dispatcher, temporalHandler, upstreamHandler)
router.SetMetricsHandler(observability.NewMetricsHandler(observability.Global()))
// Wrap router with tracing middleware
tracedRouter := tracing.Middleware(router)
-62
View File
@@ -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 .
-20
View File
@@ -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 ""
-20
View File
@@ -33,17 +33,9 @@ type ModelUpstream struct {
// Name is the model name clients send (e.g., "reasoning", "ornith:35b").
Name string
// Address is the upstream server address (host:port).
// When Addresses is set, this holds Addresses[0] for backward compatibility.
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 string
// UpstreamModel is the model name to send to the upstream server.
// If empty, the client-provided model name (Name) is used as-is.
// Use this when the upstream expects a different model name than clients send.
UpstreamModel string
// AuthRequired indicates whether this model requires JWT authentication.
AuthRequired bool
}
@@ -120,18 +112,6 @@ func (c *Config) ModelNames() []string {
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.
func Load() (*Config, error) {
listenAddr := "127.0.0.1:8080"
+12 -23
View File
@@ -37,12 +37,10 @@ type rawRoute struct {
// rawModel represents a single model entry in the YAML configuration.
type rawModel struct {
Name string `yaml:"name"`
Address string `yaml:"address"`
Addresses []string `yaml:"addresses"`
Path string `yaml:"path"`
UpstreamModel string `yaml:"upstreamModel"`
AuthRequired *bool `yaml:"authRequired"`
Name string `yaml:"name"`
Address string `yaml:"address"`
Path string `yaml:"path"`
AuthRequired *bool `yaml:"authRequired"`
}
// rawAdapter represents a service adapter in the YAML configuration.
@@ -122,18 +120,11 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
if _, exists := models[rawModel.Name]; exists {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name)
}
// Support both 'address' (single) and 'addresses' (multi-backend) YAML fields.
addresses := rawModel.Addresses
if len(addresses) == 0 && rawModel.Address != "" {
addresses = []string{rawModel.Address}
if rawModel.Address == "" {
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
}
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
authRequired := false
@@ -141,12 +132,10 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
authRequired = *rawModel.AuthRequired
}
models[rawModel.Name] = &ModelUpstream{
Name: rawModel.Name,
Address: addresses[0],
Addresses: addresses,
Path: rawModel.Path,
UpstreamModel: rawModel.UpstreamModel,
AuthRequired: authRequired,
Name: rawModel.Name,
Address: rawModel.Address,
Path: rawModel.Path,
AuthRequired: authRequired,
}
}
+1 -137
View File
@@ -161,7 +161,7 @@ models:
if err == 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)
}
}
@@ -283,139 +283,3 @@ models:
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
}())
}
-8
View File
@@ -198,11 +198,3 @@ func FromContext(ctx context.Context) *Logger {
func WithContext(ctx context.Context, l *Logger) context.Context {
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'))
}
-260
View File
@@ -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))
}
-291
View File
@@ -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)
}
-513
View File
@@ -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")
}
}
-28
View File
@@ -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()))
}
-180
View File
@@ -28,19 +28,6 @@ type Metrics struct {
// Streaming metrics
streamingResponsesTotal map[string]int64
streamingByteCount map[string]int64
// LLM inference metrics (TTFT and ITL)
// ttftMs: Time-to-First-Token in milliseconds
ttftMs map[string][]int64 // samples for histogram
// itlMs: Inter-Token Latency in milliseconds
itlMs map[string][]int64 // samples for histogram
// Token counts
tokenCount map[string]int64
// CAG metrics
warmupRequestsDelayed int64
prefixCacheHitTokens int64
queueFullRequests int64
}
// NewMetrics creates a new Metrics instance.
@@ -54,9 +41,6 @@ func NewMetrics() *Metrics {
upstreamHealth: make(map[string]int),
streamingResponsesTotal: make(map[string]int64),
streamingByteCount: make(map[string]int64),
ttftMs: make(map[string][]int64),
itlMs: make(map[string][]int64),
tokenCount: make(map[string]int64),
}
}
@@ -162,113 +146,9 @@ func (m *Metrics) GetMetrics() map[string]interface{} {
"upstream_health": m.upstreamHealth,
"streaming_responses_total": m.streamingResponsesTotal,
"streaming_byte_count": m.streamingByteCount,
"llm_ttft_ms": m.ttftMs,
"llm_itl_ms": m.itlMs,
"llm_token_count": m.tokenCount,
}
}
// RecordTTFT records Time-to-First-Token in milliseconds
func (m *Metrics) RecordTTFT(model string, ttftMs int64) {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("llm:ttft:%s", model)
m.ttftMs[key] = append(m.ttftMs[key], ttftMs)
}
// RecordITL records Inter-Token Latency in milliseconds
func (m *Metrics) RecordITL(model string, itlMs int64) {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("llm:itl:%s", model)
m.itlMs[key] = append(m.itlMs[key], itlMs)
}
// RecordTokenCount records number of tokens in response
func (m *Metrics) RecordTokenCount(model string, count int64) {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("llm:tokens:%s", model)
m.tokenCount[key] += count
}
// GetTTFTMetrics returns TTFT statistics for Prometheus export
func (m *Metrics) GetTTFTMetrics() map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]interface{})
for key, samples := range m.ttftMs {
if len(samples) > 0 {
result[key] = map[string]interface{}{
"count": len(samples),
"sum": sumInt64(samples),
"avg": sumInt64(samples) / int64(len(samples)),
"min": minInt64(samples),
"max": maxInt64(samples),
}
}
}
return result
}
// GetITLMetrics returns ITL statistics for Prometheus export
func (m *Metrics) GetITLMetrics() map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]interface{})
for key, samples := range m.itlMs {
if len(samples) > 0 {
result[key] = map[string]interface{}{
"count": len(samples),
"sum": sumInt64(samples),
"avg": sumInt64(samples) / int64(len(samples)),
"min": minInt64(samples),
"max": maxInt64(samples),
}
}
}
return result
}
func sumInt64(vals []int64) int64 {
var s int64
for _, v := range vals {
s += v
}
return s
}
func minInt64(vals []int64) int64 {
if len(vals) == 0 {
return 0
}
min := vals[0]
for _, v := range vals {
if v < min {
min = v
}
}
return min
}
func maxInt64(vals []int64) int64 {
if len(vals) == 0 {
return 0
}
max := vals[0]
for _, v := range vals {
if v > max {
max = v
}
}
return max
}
// Reset clears all metrics (for testing).
func (m *Metrics) Reset() {
m.mu.Lock()
@@ -282,64 +162,4 @@ func (m *Metrics) Reset() {
m.upstreamHealth = make(map[string]int)
m.streamingResponsesTotal = make(map[string]int64)
m.streamingByteCount = make(map[string]int64)
m.ttftMs = make(map[string][]int64)
m.itlMs = make(map[string][]int64)
m.tokenCount = make(map[string]int64)
m.warmupRequestsDelayed = 0
m.prefixCacheHitTokens = 0
m.queueFullRequests = 0
}
// 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
}
-210
View File
@@ -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
}
-63
View File
@@ -381,66 +381,3 @@ func TestBodySizeCappedDispatch(t *testing.T) {
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)
}
}
-125
View File
@@ -1,125 +0,0 @@
package proxy
import (
"bufio"
"fmt"
"net"
"net/http"
"strings"
"time"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
)
// ResponseWriterWrapper wraps an http.ResponseWriter to capture per-request LLM
// latency metrics (TTFT, ITL, generated tokens) while a streaming response is
// copied from the upstream by httputil.ReverseProxy.
//
// Only server-sent-event responses are measured. A non-streaming completion is
// written in a single Write, so recording it would emit one "TTFT" sample equal
// to the whole request latency and no ITL samples at all — numbers that look
// plausible but mean nothing.
type ResponseWriterWrapper struct {
writer http.ResponseWriter
statusCode int
metrics *observability.Metrics
model string
startTime time.Time
firstByteTime time.Time
lastWriteTime time.Time
ttftRecorded bool
streamChecked bool
streaming bool
}
// NewResponseWriterWrapper creates a wrapper for response writer.
// startTime must be the time the request entered the gateway, not the time this
// wrapper was constructed, otherwise TTFT measures the wrong interval.
func NewResponseWriterWrapper(w http.ResponseWriter, model string, metrics *observability.Metrics, startTime time.Time) *ResponseWriterWrapper {
return &ResponseWriterWrapper{
writer: w,
model: model,
metrics: metrics,
startTime: startTime,
statusCode: 200,
}
}
// Header implements http.ResponseWriter
func (w *ResponseWriterWrapper) Header() http.Header {
return w.writer.Header()
}
// isStreaming reports whether this is an SSE response. Resolved once from the
// Content-Type header, which ReverseProxy has already set before the first write.
func (w *ResponseWriterWrapper) isStreaming() bool {
if !w.streamChecked {
w.streamChecked = true
w.streaming = strings.Contains(w.writer.Header().Get("Content-Type"), "text/event-stream")
}
return w.streaming
}
// countSSETokens counts generated tokens in a chunk of an SSE stream. These
// upstreams emit one `data:` event per token, so the event count is the token
// count; the `[DONE]` terminator is not a token.
func countSSETokens(b []byte) int64 {
var n int64
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data:") && !strings.HasPrefix(line, "data: [DONE]") {
n++
}
}
return n
}
// Write implements http.ResponseWriter
func (w *ResponseWriterWrapper) Write(b []byte) (int, error) {
if len(b) > 0 && w.isStreaming() {
now := time.Now()
if !w.ttftRecorded {
w.metrics.RecordTTFT(w.model, now.Sub(w.startTime).Milliseconds())
w.ttftRecorded = true
w.firstByteTime = now
} else if itl := now.Sub(w.lastWriteTime).Milliseconds(); itl > 0 {
// Only record positive ITL: a zero means two chunks landed in the
// same millisecond, which carries no timing information.
w.metrics.RecordITL(w.model, itl)
}
w.lastWriteTime = now
if n := countSSETokens(b); n > 0 {
w.metrics.RecordTokenCount(w.model, n)
}
}
return w.writer.Write(b)
}
// WriteHeader implements http.ResponseWriter
func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
w.statusCode = statusCode
w.writer.WriteHeader(statusCode)
}
// Flush implements http.Flusher
func (w *ResponseWriterWrapper) Flush() {
if flusher, ok := w.writer.(http.Flusher); ok {
flusher.Flush()
}
}
// Hijack implements http.Hijacker for streaming
func (w *ResponseWriterWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hijacker, ok := w.writer.(http.Hijacker); ok {
return hijacker.Hijack()
}
return nil, nil, fmt.Errorf("response writer does not implement Hijacker")
}
// Unwrap exposes the underlying writer to http.ResponseController so optional
// capabilities (Flush, Hijack, deadlines) keep working through the wrapper.
func (w *ResponseWriterWrapper) Unwrap() http.ResponseWriter {
return w.writer
}
-171
View File
@@ -1,171 +0,0 @@
package proxy
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
)
// exportMetrics renders the Prometheus text exposition for m.
func exportMetrics(m *observability.Metrics) string {
return observability.NewPrometheusExporter(m).Export()
}
// requireSeries asserts the exporter emitted a sample line with the exact value.
func requireSeries(t *testing.T, out, want string) {
t.Helper()
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == want {
return
}
}
t.Errorf("missing metrics series %q\n--- got ---\n%s", want, out)
}
// TestResponseWriterWrapperRecordsSSEMetrics guards the regression where the
// LLM latency wrapper existed but was never constructed, leaving
// llm_ttft_seconds / llm_itl_seconds / llm_tokens_total as HELP-only stubs.
func TestResponseWriterWrapperRecordsSSEMetrics(t *testing.T) {
m := observability.NewMetrics()
rec := httptest.NewRecorder()
rec.Header().Set("Content-Type", "text/event-stream")
w := NewResponseWriterWrapper(rec, "reasoning", m, time.Now().Add(-50*time.Millisecond))
if _, err := w.Write([]byte("data: {\"token\":\"a\"}\n\n")); err != nil {
t.Fatalf("write: %v", err)
}
time.Sleep(5 * time.Millisecond)
if _, err := w.Write([]byte("data: {\"token\":\"b\"}\n\n")); err != nil {
t.Fatalf("write: %v", err)
}
// The terminator is not a generated token.
if _, err := w.Write([]byte("data: [DONE]\n\n")); err != nil {
t.Fatalf("write: %v", err)
}
out := exportMetrics(m)
requireSeries(t, out, `llm_ttft_seconds_count{model="reasoning"} 1`)
requireSeries(t, out, `llm_itl_seconds_count{model="reasoning"} 1`)
requireSeries(t, out, `llm_tokens_total{model="reasoning"} 2`)
}
// TestResponseWriterWrapperIgnoresNonStreaming verifies a single-shot JSON
// response does not produce a bogus TTFT sample equal to full request latency.
func TestResponseWriterWrapperIgnoresNonStreaming(t *testing.T) {
m := observability.NewMetrics()
rec := httptest.NewRecorder()
rec.Header().Set("Content-Type", "application/json")
w := NewResponseWriterWrapper(rec, "reasoning", m, time.Now().Add(-50*time.Millisecond))
if _, err := w.Write([]byte(`{"choices":[]}`)); err != nil {
t.Fatalf("write: %v", err)
}
out := exportMetrics(m)
if strings.Contains(out, `llm_ttft_seconds_count{model="reasoning"}`) {
t.Errorf("non-streaming response recorded TTFT\n--- got ---\n%s", out)
}
if strings.Contains(out, `llm_tokens_total{model="reasoning"}`) {
t.Errorf("non-streaming response recorded tokens\n--- got ---\n%s", out)
}
}
// TestResponseWriterWrapperPreservesStreaming verifies the wrapper stays
// transparent: bytes reach the client and Flush still works, so wrapping the
// writer cannot silently break SSE (issue #33).
func TestResponseWriterWrapperPreservesStreaming(t *testing.T) {
m := observability.NewMetrics()
rec := httptest.NewRecorder()
rec.Header().Set("Content-Type", "text/event-stream")
w := NewResponseWriterWrapper(rec, "reasoning", m, time.Now())
payload := "data: {\"token\":\"a\"}\n\n"
if _, err := w.Write([]byte(payload)); err != nil {
t.Fatalf("write: %v", err)
}
w.Flush()
if got := rec.Body.String(); got != payload {
t.Errorf("body = %q, want %q", got, payload)
}
if !rec.Flushed {
t.Error("Flush did not reach the underlying writer")
}
}
// TestHandlerRecordsLLMMetricsForStreaming is the end-to-end guard: it drives a
// real streaming request through the Handler, so it fails if the wrapper is ever
// constructed nowhere (the original defect) rather than merely being broken.
func TestHandlerRecordsLLMMetricsForStreaming(t *testing.T) {
// Unique model name keeps the observations isolated in the global registry.
model := fmt.Sprintf("metrics-test-%d", time.Now().UnixNano())
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
for _, tok := range []string{"a", "b", "c"} {
time.Sleep(2 * time.Millisecond) // guarantee distinct ITL samples
fmt.Fprintf(w, "data: {\"token\":%q}\n\n", tok)
if err := rc.Flush(); err != nil {
return
}
}
fmt.Fprint(w, "data: [DONE]\n\n")
_ = rc.Flush()
}))
defer upstream.Close()
cfg := &config.Config{
Models: map[string]*config.ModelUpstream{
model: {
Name: model,
Address: strings.TrimPrefix(upstream.URL, "http://"),
Path: "/v1/chat/completions",
},
},
Routes: make(map[string]*config.Route),
}
handler := New(cfg)
defer handler.Close()
srv := httptest.NewServer(handler)
defer srv.Close()
body := fmt.Sprintf(`{"model":%q,"stream":true,"messages":[{"role":"user","content":"hi"}]}`, model)
resp, err := http.Post(srv.URL+"/v1/chat/completions", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if _, err := io.ReadAll(resp.Body); err != nil {
t.Fatalf("read body: %v", err)
}
out := exportMetrics(observability.Global())
requireSeries(t, out, fmt.Sprintf(`llm_ttft_seconds_count{model=%q} 1`, model))
requireSeries(t, out, fmt.Sprintf(`llm_tokens_total{model=%q} 3`, model))
itlCount := regexp.MustCompile(fmt.Sprintf(`llm_itl_seconds_count\{model=%q\} (\d+)`, model))
match := itlCount.FindStringSubmatch(out)
if match == nil {
t.Errorf("no llm_itl_seconds samples recorded for %s\n--- got ---\n%s", model, out)
} else if match[1] == "0" {
t.Errorf("llm_itl_seconds_count = 0, want > 0")
}
}
+145 -369
View File
@@ -2,28 +2,21 @@
package proxy
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
)
@@ -43,20 +36,13 @@ type Handler struct {
defaultReadTimeout time.Duration
defaultWriteTimeout time.Duration
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.
type Route struct {
Name string
ModelName string // non-empty for model-based routes; used for scheduler lookup
Upstream *config.Upstream
Director func(*http.Request)
Name string
Upstream *config.Upstream
Director func(*http.Request)
Transport *http.Transport
}
@@ -106,47 +92,6 @@ func New(cfg *config.Config) *Handler {
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 {
// Create a transport per unique upstream address for connection reuse
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.
// This ensures connections are pooled and reused across requests to the same upstream.
func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.Transport {
h.transportsMu.RLock()
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
}
@@ -191,15 +127,6 @@ func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.T
dialer := &net.Dialer{
Timeout: up.ConnectTimeout,
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{
@@ -207,15 +134,8 @@ func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.T
DialContext: dialer.DialContext,
MaxIdleConns: 100,
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
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
@@ -228,7 +148,7 @@ func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.T
}
h.transports[addr] = transport
return transport // written under transportsMu write lock
return transport
}
// directorFunc modifies the request to be sent to the upstream.
@@ -314,11 +234,17 @@ func writeProblemDetail(w http.ResponseWriter, status int, problemType, title, d
// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Captured before any work so TTFT measures from the moment the gateway
// accepted the request, not from when the upstream response is copied.
requestStart := time.Now()
if h.handleAuthEndpoint(w, r) {
// 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
}
@@ -328,153 +254,63 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Resolve route; handles validation errors and 404.
route, ok := h.routeOrError(w, r)
if !ok {
// Handle /workflows endpoint (workflow orchestration)
if r.URL.Path == "/workflows" {
h.handleWorkflow(w, r)
return
}
// Strip spoofed identity headers from all inbound requests.
// Must happen before any routing — even unauthenticated paths.
identity.StripIncoming(r)
// JWT Authentication for /v1/* endpoints.
if !h.authenticateV1(w, r) {
return
}
// Enforce request body size cap for non-chat routes.
if !h.enforceBodyLimit(w, r, route) {
return
}
// CAG: warmup deprioritisation + per-backend semaphore (issues #45/#46).
cacheKey := r.Header.Get("X-Poimen-Cache-Key")
sched := h.schedulers[route.ModelName]
proxy, backend, ok := h.setupCAGProxy(r.Context(), w, r, route, sched, cacheKey)
if !ok {
return
}
if backend != nil {
defer backend.Release()
}
// For streaming responses (SSE, chunked), disable buffering. Issue #33.
w.Header().Set("X-Accel-Buffering", "no")
// Capture per-request LLM latency metrics (TTFT/ITL/tokens) while the
// response is copied from the upstream. Inert for non-SSE responses.
var respWriter http.ResponseWriter = w
if route.ModelName != "" {
respWriter = NewResponseWriterWrapper(w, route.ModelName, observability.Global(), requestStart)
}
proxy.ServeHTTP(respWriter, r)
}
// applyWarmup strips the X-Poimen-Warmup header, delays the request while
// honouring context cancellation, and records the metric.
// Returns false if the context was cancelled during the delay (caller must abort).
func (h *Handler) applyWarmup(ctx context.Context, r *http.Request) bool {
if r.Header.Get("X-Poimen-Warmup") != "true" {
return true
}
r.Header.Del("X-Poimen-Warmup")
r.Header.Set("X-Priority", "low")
select {
case <-time.After(h.warmupDelay):
case <-ctx.Done():
return false
}
observability.Global().IncrWarmupDelayed()
return true
}
// acquireScheduledBackend selects a backend from sched, applies warmup
// deprioritisation, acquires a concurrency slot, and patches route when the
// selected backend differs from the config default. It returns the backend
// to Release (caller must defer), the (possibly patched) route, 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) {
// Try to find a matching route (including body-based dispatch for /v1/chat/completions)
route, err := h.RouteRequest(r)
// Check if this is a model validation error (from body-based dispatch)
if validationErr, ok := err.(*modelValidationError); ok {
h.writeModelValidationError(w, validationErr, h.getValidModels())
// This is a client error, not a routing error
var status int
var problemType string
var title string
var detail string
switch validationErr.Kind {
case "invalid_json":
status = http.StatusBadRequest
problemType = "https://api.example.com/problems/invalid-request-body"
title = "Invalid Request Body"
detail = validationErr.Message
case "missing_model", "empty_model", "null_model":
status = http.StatusBadRequest
problemType = "https://api.example.com/problems/missing-model"
title = "Missing Model"
detail = "The 'model' field is required and must be a non-empty string"
case "unknown_model":
status = http.StatusBadRequest
problemType = "https://api.example.com/problems/unknown-model"
title = "Unknown Model"
detail = fmt.Sprintf("Model %q is not available. See valid_models for available options.", validationErr.Model)
default:
status = http.StatusBadRequest
problemType = "https://api.example.com/problems/invalid-request"
title = "Invalid Request"
detail = validationErr.Message
}
// Get list of valid models (only for model-related errors)
var validModels []string
if validationErr.Kind == "unknown_model" || validationErr.Kind == "missing_model" || validationErr.Kind == "empty_model" || validationErr.Kind == "null_model" {
validModels = h.getValidModels()
}
writeProblemDetail(w, status, problemType, title, detail, validModels)
logging.Errorf("client error", validationErr, map[string]string{
"path": r.URL.Path,
"method": r.Method,
"reason": validationErr.Kind,
})
return nil, false
return
}
if err != nil || route == nil {
// Route not found or error determining route
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "not found")
if err != nil {
@@ -485,180 +321,120 @@ func (h *Handler) routeOrError(w http.ResponseWriter, r *http.Request) (*Route,
} 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.
func (h *Handler) writeModelValidationError(w http.ResponseWriter, validationErr *modelValidationError, validModels []string) {
var status int
var problemType, title, detail string
var models []string
switch validationErr.Kind {
case "invalid_json":
status = http.StatusBadRequest
problemType = "https://api.example.com/problems/invalid-request-body"
title = "Invalid Request Body"
detail = validationErr.Message
case "missing_model", "empty_model", "null_model":
status = http.StatusBadRequest
problemType = "https://api.example.com/problems/missing-model"
title = "Missing Model"
detail = "The 'model' field is required and must be a non-empty string"
models = validModels
case "unknown_model":
status = http.StatusBadRequest
problemType = "https://api.example.com/problems/unknown-model"
title = "Unknown Model"
detail = fmt.Sprintf("Model %q is not available. See valid_models for available options.", validationErr.Model)
models = validModels
default:
status = http.StatusBadRequest
problemType = "https://api.example.com/problems/invalid-request"
title = "Invalid Request"
detail = validationErr.Message
return
}
writeProblemDetail(w, status, problemType, title, detail, models)
}
// Strip spoofed identity headers from all inbound requests.
// Must happen before any routing — even unauthenticated paths.
identity.StripIncoming(r)
// authenticateV1 validates JWT for /v1/* paths.
// Returns false when auth fails (response already written).
func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
if h.jwtValidator == nil {
logging.Warnf("JWT auth disabled; /v1/* request unauthenticated", map[string]string{"path": r.URL.Path})
return true
}
if !strings.HasPrefix(r.URL.Path, "/v1/") {
return true
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
writeProblemDetail(w, http.StatusUnauthorized,
"https://api.example.com/problems/unauthorized",
"Unauthorized",
"Authorization header required",
nil)
logging.Errorf("auth failed", fmt.Errorf("missing auth header"), map[string]string{
"path": r.URL.Path,
})
return false
}
claims, err := h.jwtValidator.ValidateBearerToken(authHeader)
if err != nil {
writeProblemDetail(w, http.StatusForbidden,
"https://api.example.com/problems/forbidden",
"Forbidden",
"JWT validation failed",
nil)
logging.Errorf("auth failed", err, map[string]string{
"path": r.URL.Path,
})
return false
}
identity.Inject(r, claims)
if h.config.Auth.RequiredCapability != "" {
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
writeProblemDetail(w, http.StatusForbidden,
"https://api.example.com/problems/insufficient-permissions",
"Insufficient Permissions",
fmt.Sprintf("Required capability: %s", h.config.Auth.RequiredCapability),
// JWT Authentication for /v1/* endpoints
if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
writeProblemDetail(w, http.StatusUnauthorized,
"https://api.example.com/problems/unauthorized",
"Unauthorized",
"Authorization header required",
nil)
logging.Errorf("auth failed", fmt.Errorf("insufficient permissions"), map[string]string{
"path": r.URL.Path,
"required": h.config.Auth.RequiredCapability,
logging.Errorf("auth failed", fmt.Errorf("missing auth header"), map[string]string{
"path": r.URL.Path,
})
return false
return
}
}
return true
}
// enforceBodyLimit caps request body size for non-chat routes.
// Returns false when the request is rejected (response already written).
func (h *Handler) enforceBodyLimit(w http.ResponseWriter, r *http.Request, route *Route) bool {
if r.URL.Path == "/v1/chat/completions" {
return true
}
if route.Upstream.MaxBodySize > 0 && r.ContentLength > route.Upstream.MaxBodySize {
w.WriteHeader(http.StatusRequestEntityTooLarge)
fmt.Fprintf(w, "request body too large")
logging.Errorf("request rejected", fmt.Errorf("body_too_large"), map[string]string{
"reason": "body_too_large",
"route": route.Name,
"upstream": route.Upstream.Address,
"content_length": fmt.Sprintf("%d", r.ContentLength),
"max_body_size": fmt.Sprintf("%d", route.Upstream.MaxBodySize),
})
return false
}
if route.Upstream.MaxBodySize > 0 && r.Body != nil {
r.Body = io.NopCloser(io.LimitReader(r.Body, route.Upstream.MaxBodySize))
}
return true
}
claims, err := h.jwtValidator.ValidateBearerToken(authHeader)
if err != nil {
writeProblemDetail(w, http.StatusForbidden,
"https://api.example.com/problems/forbidden",
"Forbidden",
"JWT validation failed",
nil)
logging.Errorf("auth failed", err, map[string]string{
"path": r.URL.Path,
})
return
}
// 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
// Inject identity headers for downstream services
identity.Inject(r, claims)
// Check required capability if configured
if h.config.Auth.RequiredCapability != "" {
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
writeProblemDetail(w, http.StatusForbidden,
"https://api.example.com/problems/insufficient-permissions",
"Insufficient Permissions",
fmt.Sprintf("Required capability: %s", h.config.Auth.RequiredCapability),
nil)
logging.Errorf("auth failed", fmt.Errorf("insufficient permissions"), map[string]string{
"path": r.URL.Path,
"required": h.config.Auth.RequiredCapability,
})
return
}
}
}
// Note: Body size checking already happened in RouteRequest (body was read for model dispatch).
// For other paths, we still need to enforce the cap.
// For /v1/chat/completions, the body was already read and validated.
// 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 {
w.WriteHeader(http.StatusRequestEntityTooLarge)
fmt.Fprintf(w, "request body too large")
logging.Errorf("request rejected", fmt.Errorf("body_too_large"), map[string]string{
"reason": "body_too_large",
"route": route.Name,
"upstream": route.Upstream.Address,
"content_length": fmt.Sprintf("%d", r.ContentLength),
"max_body_size": fmt.Sprintf("%d", route.Upstream.MaxBodySize),
})
return
}
// Wrap request body with size limiter
// This enforces the cap at read time, not after buffering
if route.Upstream.MaxBodySize > 0 && r.Body != nil {
r.Body = io.NopCloser(io.LimitReader(r.Body, route.Upstream.MaxBodySize))
}
}
// Create the reverse proxy
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: route.Upstream.Address,
})
// Set the director to apply path rewriting
proxy.Director = route.Director
// Use the connection-pooled transport wrapped with tracing
proxy.Transport = tracing.NewTransport(route.Transport)
capturedBackend := selectedBackend
capturedRoute := route
// Set error handler to log upstream errors
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)
fmt.Fprintf(w, "upstream error")
logging.Errorf("upstream error", err, map[string]string{
"upstream": capturedRoute.Upstream.Address,
"upstream": route.Upstream.Address,
"path": r.URL.Path,
})
}
if cacheKey != "" && r.URL.Path == "/v1/chat/completions" {
proxy.ModifyResponse = func(resp *http.Response) error {
raw := resp.Header.Get("vllm-num-cached-tokens")
if raw == "" {
return nil
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n <= 0 {
return nil
}
observability.Global().IncrPrefixCacheHitTokens(n)
logging.Event(map[string]interface{}{
"event": "llm_request",
"cache_key": cacheKey,
"cached_tokens": n,
})
return nil
}
}
// Note: We apply connection timeout via Transport dialer, but NOT read timeout as a context deadline.
// Read timeout should apply to inactivity (socket read timeout), not total request duration.
// A streaming response that's continuously sending should not be cut off.
// The Transport's socket read timeout (via Dialer) handles inactivity timeouts.
return proxy, selectedBackend, true
// Serve the request through the proxy
proxy.ServeHTTP(w, r)
}
// getValidModels returns a sorted list of all configured model names.
func (h *Handler) getValidModels() []string {
var models []string
-362
View File
@@ -1,24 +1,15 @@
package proxy
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
)
func TestProxyBasic(t *testing.T) {
@@ -423,356 +414,3 @@ func TestProxyPreservesBody(t *testing.T) {
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)
}
})
}
}
-15
View File
@@ -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
upstreamPath := path
if path == "/v1/rerank" {
@@ -159,7 +145,6 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
route := &Route{
Name: "v1-chat-" + modelName,
ModelName: modelName,
Upstream: upstreamCfg,
Transport: h.getOrCreateTransport(modelUpstream.Address, upstreamCfg),
Director: func(req *http.Request) {
-157
View File
@@ -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)
}
-182
View File
@@ -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"])
}
}
-208
View File
@@ -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,
// the upstream request context is cancelled immediately and no goroutines are leaked.
func TestClientDisconnectCancelsUpstream(t *testing.T) {
+484
View File
@@ -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())
}
+258
View File
@@ -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)
}
}
+2 -22
View File
@@ -4,7 +4,6 @@ import (
"net/http"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/webhook"
)
// Router implements an HTTP handler that routes health endpoints,
@@ -15,8 +14,6 @@ type Router struct {
dispatcher *serviceadapter.Dispatcher
temporalHandler http.Handler
upstreamHandler http.Handler
forgejoWebhook *webhook.ForgejoHandler
metricsHandler http.Handler
}
// NewRouter creates a new router with health endpoints.
@@ -26,19 +23,13 @@ type Router struct {
// All other paths are passed to the upstream handler.
func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatcher, temporalHandler http.Handler, upstreamHandler http.Handler) *Router {
return &Router{
healthChecker: healthChecker,
dispatcher: dispatcher,
healthChecker: healthChecker,
dispatcher: dispatcher,
temporalHandler: temporalHandler,
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.
// Priority order:
// 1. /healthz and /readyz to health handlers
@@ -54,11 +45,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case "/readyz":
ReadinessHandler(r.healthChecker)(w, req)
return
case "/metrics":
if r.metricsHandler != nil {
r.metricsHandler.ServeHTTP(w, req)
return
}
}
// 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
// DEPRECATED: Path-based /workflow routing is legacy.
// New clients should use X-Service: workflow header instead for consistent auth.
+13 -25
View File
@@ -6,8 +6,6 @@ import (
"net/http"
"sync"
"time"
"golang.org/x/net/http2"
)
// Server wraps an HTTP server with graceful shutdown support.
@@ -21,30 +19,20 @@ type Server struct {
// New creates a new Server with the given configuration.
func New(listenAddr string, shutdownTimeout time.Duration, handler http.Handler) *Server {
httpServer := &http.Server{
Addr: listenAddr,
Handler: handler,
// ReadHeaderTimeout (not ReadTimeout) and a long WriteTimeout: both
// ReadTimeout and WriteTimeout are absolute deadlines covering the
// whole request/response body, not inactivity timeouts -- a 15s
// WriteTimeout here was killing in-progress LLM SSE streams (proxy.go's
// outbound transport deliberately avoids this same mistake). Mirrors
// the edge nginx Ingress's proxy-read/send-timeout of 3600s.
ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 1 * time.Hour,
IdleTimeout: 60 * time.Second,
}
// Issue #32: Enable HTTP/2 for multiplexing concurrent streams.
// This allows multiple LLM requests over a single connection,
// improving throughput and reducing latency for concurrent clients.
if err := http2.ConfigureServer(httpServer, nil); err != nil {
// Silently fail HTTP/2 config (shouldn't happen, but gracefully degrade)
// Server will still work with HTTP/1.1
}
return &Server{
httpServer: httpServer,
httpServer: &http.Server{
Addr: listenAddr,
Handler: handler,
// ReadHeaderTimeout (not ReadTimeout) and a long WriteTimeout: both
// ReadTimeout and WriteTimeout are absolute deadlines covering the
// whole request/response body, not inactivity timeouts -- a 15s
// WriteTimeout here was killing in-progress LLM SSE streams (proxy.go's
// outbound transport deliberately avoids this same mistake). Mirrors
// the edge nginx Ingress's proxy-read/send-timeout of 3600s.
ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 1 * time.Hour,
IdleTimeout: 60 * time.Second,
},
shutdownTimeout: shutdownTimeout,
healthChecker: NewHealthChecker(false, false),
}
+3 -3
View File
@@ -1,5 +1,8 @@
package serviceadapter
// WorkflowAdapter handles X-Service: workflow requests.
type WorkflowAdapter struct{}
// SQSAdapter handles X-Service: sqs requests.
type SQSAdapter struct{}
@@ -28,9 +31,6 @@ func AdapterFactory(serviceName string) interface{} {
return &IAMAdapter{}
case "memory":
return &MemoryAdapter{}
case "sessions":
// code-registered; see NewSessionAdapter()
return nil
default:
return nil
}
-67
View File
@@ -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"},
}},
},
},
}
}
-100
View File
@@ -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"},
}},
},
},
}
}
+5 -22
View File
@@ -16,7 +16,6 @@ import (
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
)
@@ -75,25 +74,12 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
}
// JWT auth enforcement for adapters that require it
if adapter.Spec.Auth.Required {
if d.jwtValidator == nil {
logging.Warnf("auth required but jwtValidator nil; request unauthenticated", map[string]string{
"service": serviceName,
"resource": resourceName,
})
} else if !d.authenticate(w, r, serviceName, method) {
if adapter.Spec.Auth.Required && d.jwtValidator != nil {
if !d.authenticate(w, r, serviceName, method.Verb) {
return
}
}
// Internal handler: dispatch directly without reverse proxy
if adapter.Handler != nil {
// Set X-Upstream-Path so the handler knows which method was matched
r.Header.Set("X-Upstream-Path", method.UpstreamPath)
adapter.Handler.ServeHTTP(w, r)
return
}
upstreamURL := adapter.Spec.Upstream.URL
if strings.HasPrefix(upstreamURL, "grpc://") {
d.dispatchGRPC(w, r, upstreamURL, method, adapter)
@@ -104,7 +90,7 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
// authenticate validates the JWT and checks service-level capability.
// Returns false (and writes error response) if auth fails.
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName string, method *Method) bool {
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName, verb string) bool {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
d.writeError(w, problem.NewProblem(http.StatusUnauthorized,
@@ -121,11 +107,8 @@ func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, servic
return false
}
// Check capability: method-level Auth.Capability if set; else derive from verb.
required := capabilityForVerb(serviceName, method.Verb)
if method.Auth != nil && method.Auth.Capability != "" {
required = method.Auth.Capability
}
// Check capability: <service>:read for GET/HEAD, <service>:write for mutating verbs
required := capabilityForVerb(serviceName, verb)
if !d.jwtValidator.CheckPermissions(claims, required, "*") {
d.writeError(w, problem.NewProblem(http.StatusForbidden,
"about:blank#insufficient-permissions", "Insufficient Permissions",
-202
View File
@@ -1,19 +1,11 @@
package serviceadapter
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
)
@@ -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)
}
}
// 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())
}
})
}
-123
View File
@@ -1,123 +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"},
},
},
},
// sandbox — per-session k8s Pod+PVC+Service lifecycle (§4.15.4)
{
Name: "sandbox",
Methods: []Method{
{
Verb: "POST",
UpstreamPath: "/v1/sessions/sandbox",
Auth: &Auth{Required: true, Capability: "sessions:write"},
},
{
Verb: "DELETE",
UpstreamPath: "/v1/sessions/sandbox",
Auth: &Auth{Required: true, Capability: "sessions:write"},
},
},
},
},
}
}
+5 -9
View File
@@ -1,7 +1,6 @@
package serviceadapter
import (
"net/http"
"time"
)
@@ -50,14 +49,11 @@ type Status struct {
}
// ServiceAdapter is a gateway service adapter.
// When Handler is set, the dispatcher routes directly to the internal handler
// instead of reverse-proxying to Spec.Upstream.URL.
type ServiceAdapter struct {
Name string // namespace/name
Namespace string
Name string // namespace/name
Namespace string
ServiceName string
Spec Spec
Status Status
CreatedAt time.Time
Handler http.Handler `json:"-" yaml:"-"` // internal handler (skip serialization)
Spec Spec
Status Status
CreatedAt time.Time
}
-377
View File
@@ -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)
}
}
-97
View File
@@ -1,97 +0,0 @@
// Package sessions — FleetClient calls the SandboxFleet controller REST API
// to assign sessions to fleet pods. This is the primary sandbox path;
// the raw-k8s SandboxManager is the fallback when the fleet controller
// is unavailable (e.g. during fleet rollout).
package sessions
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
defaultFleetControllerURL = "http://tool-definition-controller.poimen.svc.cluster.local:8788"
)
// FleetClient calls the SandboxFleet controller REST API.
type FleetClient struct {
baseURL string
client *http.Client
}
// FleetAssignResult is returned by the controller assign endpoint.
type FleetAssignResult struct {
PodSvcDns string `json:"podSvcDns"`
PodName string `json:"podName"`
}
// NewFleetClient creates a FleetClient from env or default URL.
// FLEET_CONTROLLER_URL — default: defaultFleetControllerURL
func NewFleetClient() *FleetClient {
url := os.Getenv("FLEET_CONTROLLER_URL")
if url == "" {
url = defaultFleetControllerURL
}
return &FleetClient{
baseURL: url,
client: &http.Client{Timeout: 35 * time.Second},
}
}
// Assign asks the fleet controller to assign sessionID to a fleet pod.
// Returns the pod's ClusterIP service DNS and pod name.
func (f *FleetClient) Assign(ctx context.Context, sessionID string) (*FleetAssignResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
f.baseURL+"/sessions/"+sessionID+"/assign", nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := f.client.Do(req)
if err != nil {
return nil, fmt.Errorf("fleet controller unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("fleet controller returned %d: %s", resp.StatusCode, body)
}
var result FleetAssignResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
if result.PodSvcDns == "" {
return nil, fmt.Errorf("fleet controller returned empty podSvcDns")
}
return &result, nil
}
// Release notifies the fleet controller that a session has ended.
// Cleans up the per-session ServiceAccount (audit trail SA).
// Non-fatal: fleet controller handles this async.
func (f *FleetClient) Release(ctx context.Context, sessionID string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete,
f.baseURL+"/sessions/"+sessionID, bytes.NewReader(nil))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
resp, err := f.client.Do(req)
if err != nil {
return fmt.Errorf("fleet controller unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("fleet controller returned %d: %s", resp.StatusCode, body)
}
return nil
}
-634
View File
@@ -1,634 +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"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/google/uuid"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
)
const (
defaultGraphQLURL = "http://poimen-graphql.poimen.svc.cluster.local:8090/graphql"
)
// Handler handles session REST endpoints via internal service calls.
// All traffic (reads and writes) goes to graphqlURL (poimen-graphql).
// Sandbox creates/deletes pi-session pods via the SandboxFleet controller
// (primary) with SandboxManager as direct-k8s fallback.
type Handler struct {
graphqlURL string
client *http.Client
sandbox *SandboxManager
fleet *FleetClient
}
// NewHandler creates a Handler reading upstream URL from env var.
// GRAPHQL_UPSTREAM_URL — default: defaultGraphQLURL
func NewHandler() *Handler {
gql := os.Getenv("GRAPHQL_UPSTREAM_URL")
if gql == "" {
gql = defaultGraphQLURL
}
h := &Handler{
graphqlURL: gql,
client: &http.Client{Timeout: 30 * time.Second, Transport: tracing.NewTransport(nil)},
sandbox: NewSandboxManager(),
fleet: NewFleetClient(),
}
h.startIdleReaper()
return h
}
// startIdleReaper launches a background goroutine that deletes exited pi-session
// pods (bridge.js idle-timeout triggered) while preserving their PVCs.
func (h *Handler) startIdleReaper() {
intervalStr := os.Getenv("SANDBOX_REAPER_INTERVAL_MINUTES")
interval := defaultReaperInterval
if intervalStr != "" {
var min int
if n, _ := fmt.Sscanf(intervalStr, "%d", &min); n == 1 && min > 0 {
interval = time.Duration(min) * time.Minute
}
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
h.sandbox.ReapIdlePods(context.Background())
}
}()
}
// ServeHTTP routes by X-Upstream-Path set by the dispatcher.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.Header.Get("X-Upstream-Path")
switch path {
case "/v1/sessions/create":
h.handleCreate(w, r)
case "/v1/sessions/list":
h.handleList(w, r)
case "/v1/sessions/get":
h.handleGet(w, r)
case "/v1/sessions/patch":
h.handlePatch(w, r)
case "/v1/sessions/milestones/create":
h.handleCreateMilestone(w, r)
case "/v1/sessions/episodes/list":
h.handleListEpisodes(w, r)
case "/v1/sessions/episodes/get":
h.handleGetEpisode(w, r)
case "/v1/sessions/sandbox":
h.handleSandbox(w, r)
default:
writeJSON(w, http.StatusNotFound, map[string]string{"error": "sessions endpoint not found"})
}
}
// ── request / response types ──────────────────────────────────────────────────
type createSessionReq struct {
Name string `json:"name"`
Goal string `json:"goal,omitempty"`
Cwd string `json:"cwd,omitempty"`
}
type sessionResp struct {
SessionID string `json:"session_id"`
Name string `json:"name"`
Goal string `json:"goal,omitempty"`
Status string `json:"status"`
MemoryProject string `json:"memory_project"`
Cwd string `json:"cwd,omitempty"`
CreatedAt string `json:"created_at"`
}
type patchSessionReq struct {
Name *string `json:"name,omitempty"`
Goal *string `json:"goal,omitempty"`
Status *string `json:"status,omitempty"`
}
type createMilestoneReq struct {
Title string `json:"title"`
OrderIdx int `json:"order_idx"`
ParentID *string `json:"parent_id,omitempty"`
}
type milestoneResp struct {
MilestoneID string `json:"milestone_id"`
SessionID string `json:"session_id"`
Title string `json:"title"`
Status string `json:"status"`
OrderIdx int `json:"order_idx"`
ParentID *string `json:"parent_id,omitempty"`
CreatedAt string `json:"created_at"`
}
// ── handlers ──────────────────────────────────────────────────────────────────
// POST /v1/sessions (X-Resource: sessions)
func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
var req createSessionReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
return
}
sessionID := uuid.New().String()
project := "session-" + sessionID
now := time.Now().UTC().Format(time.RFC3339)
summaryBytes, err := json.Marshal(map[string]any{
"id": sessionID,
"name": req.Name,
"goal": req.Goal,
"status": "active",
"cwd": req.Cwd,
"memory_project": project,
"created_at": now,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()})
return
}
if _, err := h.upsertEntity(r, project, sessionID, "SESSION", string(summaryBytes)); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()})
return
}
writeJSON(w, http.StatusCreated, sessionResp{
SessionID: sessionID,
Name: req.Name,
Goal: req.Goal,
Status: "active",
MemoryProject: project,
Cwd: req.Cwd,
CreatedAt: now,
})
}
// GET /v1/sessions (X-Resource: sessions) ?status=active&cwd=/path
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
status := q.Get("status")
cwd := q.Get("cwd")
limitStr := q.Get("limit")
limit := 50
if limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit) //nolint:errcheck
}
if limit > 200 {
limit = 200
}
searchQuery := "session"
if status != "" {
searchQuery = fmt.Sprintf("session status:%s", status)
}
if cwd != "" {
searchQuery = fmt.Sprintf("%s cwd:%s", searchQuery, cwd)
}
// entities query (not hybridSearch) — project arg is optional, so it queries
// across ALL projects. Required because each session lives in its own
// session-<uuid> project; there is no single project to hybridSearch against.
gqlQuery := `query($q:String!,$limit:Int!){
entities(query:$q,limit:$limit,entityType:SESSION){
entities{id name summary projectId tCreated}
searchMode
}
}`
vars := map[string]any{"q": searchQuery, "limit": limit}
result, err := h.gql(r, gqlQuery, vars)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "graphql query failed: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
// GET /v1/sessions/:id (X-Resource: session) ?session_id=<uuid>
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
return
}
project := "session-" + sessionID
gqlQuery := `query($id:String!,$project:String!){
hybridSearch(query:$id,project:$project,limit:1,entityType:SESSION){
entities{id name summary tCreated}
}
}`
vars := map[string]any{"id": sessionID, "project": project}
result, err := h.gql(r, gqlQuery, vars)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "graphql query failed: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
// PATCH /v1/sessions/:id (X-Resource: session) ?session_id=<uuid>
func (h *Handler) handlePatch(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
return
}
var req patchSessionReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
now := time.Now().UTC().Format(time.RFC3339)
project := "session-" + sessionID
// Read current summary so the upsert carries all existing fields.
current, err := h.fetchEntitySummary(r, project, sessionID, "SESSION")
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "fetch failed: " + err.Error()})
return
}
current["updated_at"] = now
if req.Name != nil {
current["name"] = *req.Name
}
if req.Goal != nil {
current["goal"] = *req.Goal
}
if req.Status != nil {
current["status"] = *req.Status
if *req.Status == "archived" {
current["archived_at"] = now
}
}
summaryBytes, err := json.Marshal(current)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()})
return
}
if _, err := h.upsertEntity(r, project, sessionID, "SESSION", string(summaryBytes)); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"session_id": sessionID, "status": "updated"})
}
// POST /v1/sessions/:id/milestones (X-Resource: milestones) ?session_id=<uuid>
func (h *Handler) handleCreateMilestone(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
return
}
var req createMilestoneReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if req.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
return
}
milestoneID := uuid.New().String()
now := time.Now().UTC().Format(time.RFC3339)
project := "session-" + sessionID
msContent := map[string]any{
"type": "MILESTONE",
"id": milestoneID,
"session_id": sessionID,
"title": req.Title,
"status": "pending",
"order_idx": req.OrderIdx,
"created_at": now,
}
if req.ParentID != nil {
msContent["parent_id"] = *req.ParentID
}
summaryBytes, err := json.Marshal(msContent)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()})
return
}
if _, err := h.upsertEntity(r, project, milestoneID, "MILESTONE", string(summaryBytes)); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()})
return
}
writeJSON(w, http.StatusCreated, milestoneResp{
MilestoneID: milestoneID,
SessionID: sessionID,
Title: req.Title,
Status: "pending",
OrderIdx: req.OrderIdx,
ParentID: req.ParentID,
CreatedAt: now,
})
}
// GET /v1/sessions/:id/episodes (X-Resource: episodes) ?session_id=<uuid>
func (h *Handler) handleListEpisodes(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
return
}
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit) //nolint:errcheck
}
if limit > 200 {
limit = 200
}
project := "session-" + sessionID
gqlQuery := `query($project:String!,$limit:Int!){
hybridSearch(query:"episode",project:$project,limit:$limit,entityType:EPISODE){
entities{id name summary tCreated}
searchMode
}
}`
vars := map[string]any{"project": project, "limit": limit}
result, err := h.gql(r, gqlQuery, vars)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "graphql query failed: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
// GET /v1/sessions/:id/episodes/:eid (X-Resource: episode)
// ?session_id=<uuid>&episode_id=<sha256>
func (h *Handler) handleGetEpisode(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
sessionID := q.Get("session_id")
episodeID := q.Get("episode_id")
if sessionID == "" || episodeID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "session_id and episode_id query params required",
})
return
}
gqlQuery := `query($id:ID!){
entity(id:$id){
id name entityType summary tCreated
edges{
relationType fact confidence
target{name entityType}
}
}
}`
vars := map[string]any{"id": episodeID}
result, err := h.gql(r, gqlQuery, vars)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "graphql query failed: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
// ── helpers ───────────────────────────────────────────────────────────────────
// upsertEntity runs a GraphQL upsertEntity mutation on poimen-graphql.
// Writes are direct (no LLM extraction). Returns the entity id assigned by the server.
// The name field is used as the stable upsert key within (project, entityType).
func (h *Handler) upsertEntity(r *http.Request, project, name, entityType, summary string) (string, error) {
mutation := `mutation($input:UpsertEntityInput!){
upsertEntity(input:$input){ id created }
}`
vars := map[string]any{
"input": map[string]any{
"project": project,
"name": name,
"entityType": entityType,
"summary": summary,
},
}
result, err := h.gql(r, mutation, vars)
if err != nil {
return "", err
}
data, _ := result.(map[string]any)
d, _ := data["data"].(map[string]any)
entity, _ := d["upsertEntity"].(map[string]any)
id, _ := entity["id"].(string)
return id, nil
}
// fetchEntitySummary fetches the current summary blob of an entity by (project, name, entityType).
// Returns a parsed map so callers can merge patch fields before re-upserting.
// Returns empty map (not error) if no entity found.
func (h *Handler) fetchEntitySummary(r *http.Request, project, name, entityType string) (map[string]any, error) {
gqlQuery := `query($q:String!,$project:String!,$entityType:GqlEntityType!,$limit:Int!){
hybridSearch(query:$q,project:$project,limit:$limit,entityType:$entityType){
entities{id name summary tCreated}
}
}`
vars := map[string]any{
"q": name,
"project": project,
"entityType": entityType,
"limit": 1,
}
result, err := h.gql(r, gqlQuery, vars)
if err != nil {
return nil, err
}
data, _ := result.(map[string]any)
d, _ := data["data"].(map[string]any)
search, _ := d["hybridSearch"].(map[string]any)
entities, _ := search["entities"].([]any)
if len(entities) == 0 {
return map[string]any{}, nil
}
entity, _ := entities[0].(map[string]any)
summaryStr, _ := entity["summary"].(string)
var summary map[string]any
if summaryStr != "" {
if err := json.Unmarshal([]byte(summaryStr), &summary); err != nil {
return map[string]any{}, nil // corrupt summary — start fresh
}
}
if summary == nil {
summary = map[string]any{}
}
return summary, nil
}
// gql executes a GraphQL query against poimen-graphql, forwarding the caller's JWT.
func (h *Handler) gql(r *http.Request, query string, variables map[string]any) (any, error) {
payload, err := json.Marshal(map[string]any{
"query": query,
"variables": variables,
})
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost,
h.graphqlURL, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if auth := r.Header.Get("Authorization"); auth != "" {
req.Header.Set("Authorization", auth)
}
resp, err := h.client.Do(req)
if err != nil {
return nil, fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("graphql returned %d", resp.StatusCode)
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
if errs, ok := result["errors"]; ok && errs != nil {
b, _ := json.Marshal(errs)
return nil, fmt.Errorf("graphql errors: %s", b)
}
return result, nil
}
// handleSandbox dispatches POST (create/resume) and DELETE (teardown).
// Both take ?session_id= as a query param.
func (h *Handler) handleSandbox(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
return
}
switch r.Method {
case http.MethodPost:
h.handleSandboxCreate(w, r, sessionID)
case http.MethodDelete:
h.handleSandboxDelete(w, r, sessionID)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}
// POST ?session_id= — assign session to SandboxFleet pod (primary) or
// create a dedicated pod via SandboxManager (fallback).
func (h *Handler) handleSandboxCreate(w http.ResponseWriter, r *http.Request, sessionID string) {
// Primary: fleet controller assigns session to a pre-warmed pod.
if assign, err := h.fleet.Assign(r.Context(), sessionID); err == nil {
svcDns := assign.PodSvcDns + fmt.Sprintf(":%d", sandboxPort)
log.Printf("sandbox: fleet assigned session=%s pod=%s dns=%s", sessionID, assign.PodName, svcDns)
go h.updateSandboxSummary(r, sessionID, assign.PodName, "", svcDns)
writeJSON(w, http.StatusOK, map[string]any{
"session_id": sessionID,
"pod_name": assign.PodName,
"svc_dns": svcDns,
"resumed": false,
"fleet": true,
})
return
} else {
log.Printf("sandbox: fleet unavailable for session=%s (%v), falling back to direct k8s", sessionID, err)
}
// Fallback: create a dedicated pod via SandboxManager.
result, err := h.sandbox.CreateOrResume(r.Context(), sessionID)
if err != nil {
log.Printf("sandbox create/resume session=%s: %v", sessionID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "sandbox error: " + err.Error()})
return
}
go h.updateSandboxSummary(r, sessionID, result.PodName, result.PVCName, result.SvcDNS)
log.Printf("sandbox: direct pod session=%s pod=%s resumed=%v dns=%s", sessionID, result.PodName, result.Resumed, result.SvcDNS)
writeJSON(w, http.StatusOK, result)
}
// updateSandboxSummary merges pod/svc metadata into the session memory_entity (fire-and-forget).
// Uses context.Background() because r.Context() is cancelled once the HTTP handler returns.
func (h *Handler) updateSandboxSummary(r *http.Request, sessionID, podName, pvcName, svcDns string) {
// Build a detached request carrying just the auth header.
detached, _ := http.NewRequestWithContext(context.Background(), r.Method, r.URL.String(), nil)
if auth := r.Header.Get("Authorization"); auth != "" {
detached.Header.Set("Authorization", auth)
}
current, _ := h.fetchEntitySummary(detached, "session-"+sessionID, sessionID, "SESSION")
if current == nil {
current = map[string]any{}
}
current["pod_name"] = podName
current["svc_dns"] = svcDns
current["last_active_at"] = time.Now().UTC().Format(time.RFC3339)
if pvcName != "" {
current["pvc_name"] = pvcName
}
if b, err := json.Marshal(current); err == nil {
h.upsertEntity(r, "session-"+sessionID, sessionID, "SESSION", string(b)) //nolint:errcheck
}
}
// DELETE ?session_id= — release from fleet + delete dedicated pod+svc+pvc if any.
func (h *Handler) handleSandboxDelete(w http.ResponseWriter, r *http.Request, sessionID string) {
// Release from fleet (cleans up per-session SA; non-fatal if session was fleet-based).
if err := h.fleet.Release(r.Context(), sessionID); err != nil {
log.Printf("sandbox: fleet release session=%s: %v (continuing)", sessionID, err)
}
// Also attempt direct k8s cleanup (no-ops if resources don't exist).
if err := h.sandbox.Delete(r.Context(), sessionID); err != nil {
log.Printf("sandbox delete session=%s: %v", sessionID, err)
}
log.Printf("sandbox: released session=%s", sessionID)
writeJSON(w, http.StatusOK, map[string]string{"session_id": sessionID, "status": "deleted"})
}
// EpisodeID computes the deterministic episode ID per §4.14 Q6:
// sha256(session_id + milestone_id + attempt).
// Exported so callers can verify the value the server would produce.
func EpisodeID(sessionID, milestoneID string, attempt int) string {
h := sha256.New()
h.Write([]byte(fmt.Sprintf("%s%s%d", sessionID, milestoneID, attempt)))
return fmt.Sprintf("%x", h.Sum(nil))
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data) //nolint:errcheck
}
-594
View File
@@ -1,594 +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 (get, patch, episodes handlers)
// - "entities" → list response (sessions list handler)
// - "entity" → single entity response
func newGQLServer(t *testing.T, captured *[]byte) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if captured != nil {
*captured = body
}
w.Header().Set("Content-Type", "application/json")
bs := string(body)
switch {
case strings.Contains(bs, "upsertEntity"):
fmt.Fprint(w, `{"data":{"upsertEntity":{"id":"test-entity-id","created":true}}}`)
case strings.Contains(bs, "hybridSearch"):
// get, patch, episodes handlers still use hybridSearch
fmt.Fprint(w, `{"data":{"hybridSearch":{"entities":[]}}}`)
case strings.Contains(bs, "entities"):
// sessions list handler uses entities query
fmt.Fprint(w, `{"data":{"entities":{"entities":[]}}}`)
case strings.Contains(bs, "entity"):
fmt.Fprint(w, `{"data":{"entity":null}}`)
default:
fmt.Fprint(w, `{"data":{}}`)
}
}))
}
// buildHandler wires a Handler with the supplied graphql URL.
func buildHandler(gqlURL string) *Handler {
return &Handler{
graphqlURL: gqlURL,
client: &http.Client{Timeout: 5 * time.Second},
}
}
// do fires a request through the handler ServeHTTP. path sets X-Upstream-Path.
func do(h *Handler, method, path, upstreamPath, body, query string) *httptest.ResponseRecorder {
var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
} else {
bodyReader = http.NoBody
}
url := "/"
if query != "" {
url = "/?" + query
}
req := httptest.NewRequest(method, url, bodyReader)
req.Header.Set("X-Upstream-Path", upstreamPath)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
_ = path // kept for readability in callers
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
// jsonBody decodes response body into map.
func jsonBody(t *testing.T, rr *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode body: %v — raw: %s", err, rr.Body.String())
}
return out
}
// ── 1. handleCreate ───────────────────────────────────────────────────────────
func TestHandleCreate(t *testing.T) {
t.Run("missing name → 400", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
`{"goal":"some goal"}`, "")
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rr.Code)
}
})
t.Run("bad JSON → 400", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
`not json`, "")
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rr.Code)
}
})
t.Run("upsert graphql error → 502", func(t *testing.T) {
gqlErr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`)
}))
defer gqlErr.Close()
h := buildHandler(gqlErr.URL)
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
`{"name":"fail-session"}`, "")
if rr.Code != http.StatusBadGateway {
t.Fatalf("want 502, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "upsert failed:") {
t.Fatalf("want 'upsert failed:' in body, got: %s", rr.Body.String())
}
})
t.Run("upsert 200 → 201 with session_id, status=active, memory_project", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
`{"name":"my-session","goal":"finish task"}`, "")
if rr.Code != http.StatusCreated {
t.Fatalf("want 201, got %d — body: %s", rr.Code, rr.Body.String())
}
body := jsonBody(t, rr)
if _, ok := body["session_id"]; !ok {
t.Fatal("missing session_id")
}
if body["status"] != "active" {
t.Fatalf("want status=active, got %v", body["status"])
}
proj, _ := body["memory_project"].(string)
if !strings.HasPrefix(proj, "session-") {
t.Fatalf("memory_project must start with 'session-', got %s", proj)
}
})
}
// ── 2. handleList ─────────────────────────────────────────────────────────────
func TestHandleList(t *testing.T) {
t.Run("no params → 200, gql called with query containing 'session'", func(t *testing.T) {
var captured []byte
gql := newGQLServer(t, &captured)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "")
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d", rr.Code)
}
if !strings.Contains(string(captured), "session") {
t.Fatalf("gql payload missing 'session': %s", captured)
}
})
t.Run("status=paused → gql query contains 'status:paused'", func(t *testing.T) {
var captured []byte
gql := newGQLServer(t, &captured)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "status=paused")
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d", rr.Code)
}
if !strings.Contains(string(captured), "status:paused") {
t.Fatalf("gql payload missing 'status:paused': %s", captured)
}
})
t.Run("graphql errors body → 502", func(t *testing.T) {
gql2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`)
}))
defer gql2.Close()
h2 := buildHandler(gql2.URL)
rr := do(h2, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "")
if rr.Code != http.StatusBadGateway {
t.Fatalf("want 502, got %d — body: %s", rr.Code, rr.Body.String())
}
})
}
// ── 3. handleGet ─────────────────────────────────────────────────────────────
func TestHandleGet(t *testing.T) {
t.Run("missing session_id → 400", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "")
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rr.Code)
}
})
t.Run("success → 200, gql called with session-scoped project", func(t *testing.T) {
var captured []byte
gql := newGQLServer(t, &captured)
defer gql.Close()
h := buildHandler(gql.URL)
sid := "abc-123"
rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "session_id="+sid)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String())
}
if !strings.Contains(string(captured), "session-"+sid) {
t.Fatalf("gql payload missing scoped project 'session-%s': %s", sid, captured)
}
})
}
// ── 4. handlePatch ────────────────────────────────────────────────────────────
func TestHandlePatch(t *testing.T) {
t.Run("missing session_id → 400", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
`{"status":"paused"}`, "")
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rr.Code)
}
})
t.Run("bad JSON → 400", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
`not json`, "session_id=abc-123")
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rr.Code)
}
})
t.Run("upsert success → 200 with session_id, status=updated", func(t *testing.T) {
var secondCall []byte
callCount := 0
gqlServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
bodyStr := string(body)
w.Header().Set("Content-Type", "application/json")
callCount++
if strings.Contains(bodyStr, "hybridSearch") {
summary := `{"id":"sess-1","name":"abc-123","status":"active","goal":"old-goal"}`
fmt.Fprintf(w, `{"data":{"hybridSearch":{"entities":[{"id":"eid","name":"abc-123","summary":%q,"tCreated":"2026-01-01T00:00:00Z"}]}}}`, summary)
} else {
secondCall = body
fmt.Fprint(w, `{"data":{"upsertEntity":{"id":"eid","created":false}}}`)
}
}))
defer gqlServer.Close()
h := buildHandler(gqlServer.URL)
sid := "abc-123"
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
`{"status":"paused"}`, "session_id="+sid)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String())
}
body := jsonBody(t, rr)
if body["session_id"] != sid {
t.Fatalf("want session_id=%s, got %v", sid, body["session_id"])
}
if body["status"] != "updated" {
t.Fatalf("want status=updated, got %v", body["status"])
}
if callCount != 2 {
t.Fatalf("want 2 gql calls (hybridSearch+upsert), got %d", callCount)
}
if !strings.Contains(string(secondCall), "paused") {
t.Fatalf("upsert payload missing merged status: %s", secondCall)
}
})
t.Run("upsert error → 502", func(t *testing.T) {
gqlServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
w.Header().Set("Content-Type", "application/json")
if strings.Contains(string(body), "hybridSearch") {
fmt.Fprint(w, `{"data":{"hybridSearch":{"entities":[]}}}`)
} else {
fmt.Fprint(w, `{"errors":[{"message":"write error"}]}`)
}
}))
defer gqlServer.Close()
h := buildHandler(gqlServer.URL)
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
`{"status":"paused"}`, "session_id=abc-123")
if rr.Code != http.StatusBadGateway {
t.Fatalf("want 502, got %d — body: %s", rr.Code, rr.Body.String())
}
})
}
// ── 5. handleCreateMilestone ─────────────────────────────────────────────────
func TestHandleCreateMilestone(t *testing.T) {
t.Run("missing session_id → 400", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
`{"title":"step 1"}`, "")
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rr.Code)
}
})
t.Run("missing title → 400", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
`{"order_idx":1}`, "session_id=abc-123")
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rr.Code)
}
})
t.Run("upsert error → 502", func(t *testing.T) {
gqlErr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"errors":[{"message":"write error"}]}`)
}))
defer gqlErr.Close()
h := buildHandler(gqlErr.URL)
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
`{"title":"step 1","order_idx":0}`, "session_id=abc-123")
if rr.Code != http.StatusBadGateway {
t.Fatalf("want 502, got %d — body: %s", rr.Code, rr.Body.String())
}
})
t.Run("success → 201 with milestone_id, session_id, status=pending", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
sid := "abc-123"
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
`{"title":"phase 1","order_idx":0}`, "session_id="+sid)
if rr.Code != http.StatusCreated {
t.Fatalf("want 201, got %d — body: %s", rr.Code, rr.Body.String())
}
body := jsonBody(t, rr)
if _, ok := body["milestone_id"]; !ok {
t.Fatal("missing milestone_id")
}
if body["session_id"] != sid {
t.Fatalf("want session_id=%s, got %v", sid, body["session_id"])
}
if body["status"] != "pending" {
t.Fatalf("want status=pending, got %v", body["status"])
}
})
}
// ── 6. handleListEpisodes ─────────────────────────────────────────────────────
func TestHandleListEpisodes(t *testing.T) {
t.Run("missing session_id → 400", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list",
"", "")
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rr.Code)
}
})
t.Run("limit > 200 clamped → gql called with limit=200", func(t *testing.T) {
var captured []byte
gql := newGQLServer(t, &captured)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list",
"", "session_id=abc-123&limit=999")
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String())
}
// Verify GQL was called (body non-empty).
if len(captured) == 0 {
t.Fatal("gql not called")
}
// GQL was called; handler clamps limit to 200.
})
t.Run("success → 200", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list",
"", "session_id=abc-123")
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String())
}
})
}
// ── 7. handleGetEpisode ───────────────────────────────────────────────────────
func TestHandleGetEpisode(t *testing.T) {
t.Run("missing session_id → 400", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get",
"", "episode_id=deadbeef")
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rr.Code)
}
})
t.Run("missing episode_id → 400", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get",
"", "session_id=abc-123")
if rr.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rr.Code)
}
})
t.Run("success → 200", func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get",
"", "session_id=abc-123&episode_id=deadbeef")
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String())
}
})
}
// ── 8. EpisodeID helper ───────────────────────────────────────────────────────
func TestEpisodeID(t *testing.T) {
sid := "session-abc"
mid := "milestone-xyz"
attempt := 1
h := sha256.New()
h.Write([]byte(fmt.Sprintf("%s%s%d", sid, mid, attempt)))
expected := fmt.Sprintf("%x", h.Sum(nil))
got := EpisodeID(sid, mid, attempt)
if got != expected {
t.Fatalf("EpisodeID mismatch: want %s, got %s", expected, got)
}
// Sanity: different inputs differ.
if EpisodeID(sid, mid, 2) == got {
t.Fatal("attempt 2 must differ from attempt 1")
}
}
// ── 9. unknown path → 404 ────────────────────────────────────────────────────
func TestUnknownPath(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodGet, "/v1/sessions/unknown", "/v1/sessions/unknown", "", "")
if rr.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d", rr.Code)
}
body := jsonBody(t, rr)
if body["error"] == nil {
t.Fatal("missing error field in 404 response")
}
}
// ── table-driven summary for handleCreate (extra variants) ───────────────────
func TestHandleCreate_Table(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
}{
{"valid", `{"name":"s1"}`, http.StatusCreated},
{"empty name", `{"name":""}`, http.StatusBadRequest},
{"empty_name_string", `{"name":""}`, http.StatusBadRequest},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", tc.body, "")
if rr.Code != tc.wantStatus {
t.Fatalf("want %d, got %d — body: %s", tc.wantStatus, rr.Code, rr.Body.String())
}
})
}
}
// ── table-driven summary for handlePatch ─────────────────────────────────────
func TestHandlePatch_Table(t *testing.T) {
validSID := "session_id=abc-123"
tests := []struct {
name string
query string
body string
wantStatus int
}{
{"ok", validSID, `{"status":"paused"}`, http.StatusOK},
{"no session_id", "", `{"status":"paused"}`, http.StatusBadRequest},
{"bad json", validSID, `{bad}`, http.StatusBadRequest},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gql := newGQLServer(t, nil)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
tc.body, tc.query)
if rr.Code != tc.wantStatus {
t.Fatalf("want %d, got %d — body: %s", tc.wantStatus, rr.Code, rr.Body.String())
}
})
}
}
// ── verify upsertEntity mutation payload ─────────────────────────────────────
func TestHandleCreate_IngestPayload(t *testing.T) {
var captured []byte
gql := newGQLServer(t, &captured)
defer gql.Close()
h := buildHandler(gql.URL)
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
`{"name":"test-session","goal":"do stuff"}`, "")
if rr.Code != http.StatusCreated {
t.Fatalf("want 201, got %d", rr.Code)
}
bs := string(captured)
if !strings.Contains(bs, "upsertEntity") {
t.Fatalf("gql body missing 'upsertEntity': %s", bs)
}
if !strings.Contains(bs, "SESSION") {
t.Fatalf("gql body missing 'SESSION': %s", bs)
}
}
-492
View File
@@ -1,492 +0,0 @@
// Package sessions — SandboxManager provisions per-session k8s resources:
//
// PVC workspace-session-<uuid> Longhorn 1Gi
// Pod pi-session-<uuid> single container, /workspace <- PVC
// Svc pi-session-<uuid> ClusterIP :8787
// NetPol pi-session-sandbox shared for all pi-session pods (created once)
//
// All k8s API calls use raw HTTP + in-cluster SA token (no client-go).
// Same pattern as internal/webhook/promoter.go.
package sessions
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
const (
sandboxNamespace = "poimen"
sandboxImage = "forgejo.riotpiao.com/riotpiao-poimen/poimen-pi-session-runtime:latest"
sandboxPort = 8787
defaultPVCSize = "1Gi"
defaultStorageClass = "longhorn"
defaultReadyTimeout = 30 * time.Second
defaultReaperInterval = 5 * time.Minute
sandboxSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
sandboxCACertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
)
// SandboxManager manages the k8s lifecycle of pi-session Pods, PVCs, Services.
type SandboxManager struct {
k8sHost string
k8sClient *http.Client
pvcSize string
readyTimeout time.Duration
}
// SandboxResult is returned by CreateOrResume.
type SandboxResult struct {
SessionID string `json:"session_id"`
PodName string `json:"pod_name"`
PVCName string `json:"pvc_name"`
SvcDNS string `json:"svc_dns"`
Resumed bool `json:"resumed"` // true = pod already existed
}
// NewSandboxManager creates a SandboxManager from env + in-cluster SA files.
func NewSandboxManager() *SandboxManager {
host := os.Getenv("K8S_API_HOST")
if host == "" {
host = "https://kubernetes.default.svc"
}
pvcSize := os.Getenv("SANDBOX_PVC_SIZE")
if pvcSize == "" {
pvcSize = defaultPVCSize
}
caCert, _ := os.ReadFile(sandboxCACertPath)
return &SandboxManager{
k8sHost: host,
k8sClient: buildSandboxK8sClient(caCert),
pvcSize: pvcSize,
readyTimeout: defaultReadyTimeout,
}
}
func buildSandboxK8sClient(caCert []byte) *http.Client {
transport := &http.Transport{}
if len(caCert) > 0 {
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
transport.TLSClientConfig = &tls.Config{RootCAs: pool}
} else {
log.Printf("sandbox: WARNING no SA CA cert at %s — using InsecureSkipVerify", sandboxCACertPath)
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
}
return &http.Client{Timeout: 30 * time.Second, Transport: transport}
}
func sandboxSAToken() string {
b, err := os.ReadFile(sandboxSATokenPath)
if err != nil {
log.Printf("sandbox: WARNING failed to read SA token %s: %v", sandboxSATokenPath, err)
return ""
}
return strings.TrimSpace(string(b))
}
func pvcName(sessionID string) string { return "workspace-session-" + sessionID }
func podName(sessionID string) string { return "pi-session-" + sessionID }
func svcName(sessionID string) string { return "pi-session-" + sessionID }
func svcDNS(sessionID string) string {
return fmt.Sprintf("%s.%s.svc.cluster.local:%d", svcName(sessionID), sandboxNamespace, sandboxPort)
}
// ── public API ────────────────────────────────────────────────────────────
// CreateOrResume provisions or resumes a sandbox for sessionID.
// Idempotent: if the pod is already Running, returns its DNS immediately.
func (s *SandboxManager) CreateOrResume(ctx context.Context, sessionID string) (*SandboxResult, error) {
res := &SandboxResult{
SessionID: sessionID,
PodName: podName(sessionID),
PVCName: pvcName(sessionID),
SvcDNS: svcDNS(sessionID),
}
// 1. Ensure PVC exists
if err := s.ensurePVC(ctx, sessionID); err != nil {
return nil, fmt.Errorf("ensure PVC: %w", err)
}
// 2. Check if pod already running (resume case)
phase, err := s.getPodPhase(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("check pod: %w", err)
}
if phase == "Running" {
res.Resumed = true
return res, nil
}
// 3. Ensure NetworkPolicy for all pi-session pods exists
if err := s.ensureNetworkPolicy(ctx); err != nil {
log.Printf("sandbox: WARNING failed to ensure network policy: %v", err)
}
// 4. Create Pod + Service
if err := s.createPod(ctx, sessionID); err != nil {
return nil, fmt.Errorf("create pod: %w", err)
}
if err := s.ensureService(ctx, sessionID); err != nil {
return nil, fmt.Errorf("ensure service: %w", err)
}
// 5. Wait for Ready
if err := s.waitPodReady(ctx, sessionID); err != nil {
return nil, fmt.Errorf("wait ready: %w", err)
}
return res, nil
}
// Delete removes the Pod, Service, and PVC for sessionID.
func (s *SandboxManager) Delete(ctx context.Context, sessionID string) error {
var errs []string
for _, fn := range []func(context.Context, string) error{
s.deletePod, s.deleteService, s.deletePVC,
} {
if err := fn(ctx, sessionID); err != nil && !isNotFound(err) {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("delete sandbox: %s", strings.Join(errs, "; "))
}
return nil
}
// ReapIdlePods lists all pi-session-* pods and deletes those that have exited
// (Succeeded/Failed) — bridge.js self-exits after idle timeout.
// The PVC is preserved so the next CreateOrResume reattaches the same workspace.
func (s *SandboxManager) ReapIdlePods(ctx context.Context) {
pods, err := s.listSandboxPods(ctx)
if err != nil {
log.Printf("sandbox reaper: list pods: %v", err)
return
}
for _, podAny := range pods {
pod, _ := podAny.(map[string]any)
phase, _ := pod["status"].(map[string]any)["phase"].(string)
name, _ := pod["metadata"].(map[string]any)["name"].(string)
if phase == "Succeeded" || phase == "Failed" {
log.Printf("sandbox reaper: deleting exited pod %s (phase=%s)", name, phase)
if err := s.k8sDelete(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods/"+name); err != nil && !isNotFound(err) {
log.Printf("sandbox reaper: delete pod %s: %v", name, err)
}
}
}
}
// ── k8s resource helpers ─────────────────────────────────────────────────
func (s *SandboxManager) ensurePVC(ctx context.Context, sessionID string) error {
name := pvcName(sessionID)
_, err := s.k8sGet(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/persistentvolumeclaims/"+name)
if err == nil {
return nil // already exists
}
if !isNotFound(err) {
return err
}
// Create
body := map[string]any{
"apiVersion": "v1",
"kind": "PersistentVolumeClaim",
"metadata": map[string]any{
"name": name, "namespace": sandboxNamespace,
"labels": map[string]string{"app": "pi-session", "session-id": sessionID},
},
"spec": map[string]any{
"accessModes": []string{"ReadWriteOnce"},
"storageClassName": defaultStorageClass,
"resources": map[string]any{
"requests": map[string]string{"storage": s.pvcSize},
},
},
}
return s.k8sCreate(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/persistentvolumeclaims", body)
}
func (s *SandboxManager) getPodPhase(ctx context.Context, sessionID string) (string, error) {
pod, err := s.k8sGet(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods/"+podName(sessionID))
if isNotFound(err) {
return "", nil
}
if err != nil {
return "", err
}
phase, _ := pod["status"].(map[string]any)["phase"].(string)
return phase, nil
}
func (s *SandboxManager) createPod(ctx context.Context, sessionID string) error {
body := map[string]any{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{
"name": podName(sessionID), "namespace": sandboxNamespace,
"labels": map[string]string{"app": "pi-session", "session-id": sessionID},
},
"spec": map[string]any{
"restartPolicy": "Never",
"automountServiceAccountToken": false,
"securityContext": map[string]any{
"runAsNonRoot": true,
"runAsUser": int64(1000),
"seccompProfile": map[string]string{"type": "RuntimeDefault"},
},
"containers": []map[string]any{{
"name": "pi-session",
"image": sandboxImage,
"ports": []map[string]any{{"containerPort": sandboxPort}},
"securityContext": map[string]any{
"readOnlyRootFilesystem": true,
"allowPrivilegeEscalation": false,
"capabilities": map[string]any{"drop": []string{"ALL"}},
},
"resources": map[string]any{
"requests": map[string]string{"cpu": "500m", "memory": "512Mi"},
"limits": map[string]string{"cpu": "1", "memory": "1Gi"},
},
"readinessProbe": map[string]any{
"tcpSocket": map[string]any{"port": sandboxPort},
"initialDelaySeconds": 3,
"periodSeconds": 2,
"failureThreshold": 15,
},
"volumeMounts": []map[string]any{
{"name": "workspace", "mountPath": "/workspace"},
{"name": "tmp", "mountPath": "/tmp"},
},
}},
"volumes": []map[string]any{
{"name": "workspace", "persistentVolumeClaim": map[string]string{"claimName": pvcName(sessionID)}},
{"name": "tmp", "emptyDir": map[string]any{}},
},
},
}
return s.k8sCreate(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods", body)
}
func (s *SandboxManager) ensureService(ctx context.Context, sessionID string) error {
name := svcName(sessionID)
_, err := s.k8sGet(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/services/"+name)
if err == nil {
return nil
}
if !isNotFound(err) {
return err
}
body := map[string]any{
"apiVersion": "v1",
"kind": "Service",
"metadata": map[string]any{
"name": name, "namespace": sandboxNamespace,
"labels": map[string]string{"app": "pi-session", "session-id": sessionID},
},
"spec": map[string]any{
"type": "ClusterIP",
"selector": map[string]string{"app": "pi-session", "session-id": sessionID},
"ports": []map[string]any{{
"port": sandboxPort, "targetPort": sandboxPort, "protocol": "TCP",
}},
},
}
return s.k8sCreate(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/services", body)
}
func (s *SandboxManager) ensureNetworkPolicy(ctx context.Context) error {
const npName = "pi-session-sandbox"
_, err := s.k8sGet(ctx, "/apis/networking.k8s.io/v1/namespaces/"+sandboxNamespace+"/networkpolicies/"+npName)
if err == nil {
return nil // already exists
}
if !isNotFound(err) {
return err
}
body := map[string]any{
"apiVersion": "networking.k8s.io/v1",
"kind": "NetworkPolicy",
"metadata": map[string]any{
"name": npName, "namespace": sandboxNamespace,
"labels": map[string]string{"app": "pi-session"},
},
"spec": map[string]any{
"podSelector": map[string]any{"matchLabels": map[string]string{"app": "pi-session"}},
"policyTypes": []string{"Ingress", "Egress"},
"ingress": []map[string]any{{
"from": []map[string]any{{
"podSelector": map[string]any{"matchLabels": map[string]string{"app": "poimen-frontend"}},
}},
"ports": []map[string]any{{"protocol": "TCP", "port": sandboxPort}},
}},
"egress": []map[string]any{
// DNS
{
"to": []map[string]any{{"namespaceSelector": map[string]any{"matchLabels": map[string]string{"kubernetes.io/metadata.name": "kube-system"}}}},
"ports": []map[string]any{{"protocol": "UDP", "port": 53}, {"protocol": "TCP", "port": 53}},
},
// api-gateway (LLM inference)
{
"to": []map[string]any{{"namespaceSelector": map[string]any{"matchLabels": map[string]string{"kubernetes.io/metadata.name": "api"}}}},
"ports": []map[string]any{{"protocol": "TCP", "port": 8080}},
},
},
},
}
return s.k8sCreate(ctx, "/apis/networking.k8s.io/v1/namespaces/"+sandboxNamespace+"/networkpolicies", body)
}
func (s *SandboxManager) waitPodReady(ctx context.Context, sessionID string) error {
deadline := time.Now().Add(s.readyTimeout)
for time.Now().Before(deadline) {
pod, err := s.k8sGet(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods/"+podName(sessionID))
if err != nil {
return fmt.Errorf("poll pod: %w", err)
}
status, _ := pod["status"].(map[string]any)
phase, _ := status["phase"].(string)
if phase == "Failed" || phase == "Succeeded" {
return fmt.Errorf("pod entered terminal phase %s before ready", phase)
}
if phase == "Running" {
for _, cond := range toSlice(status["conditions"]) {
if c, ok := cond.(map[string]any); ok {
if c["type"] == "Ready" && c["status"] == "True" {
return nil
}
}
}
}
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("pod not ready after %v", s.readyTimeout)
}
func (s *SandboxManager) deletePod(ctx context.Context, sessionID string) error {
return s.k8sDelete(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods/"+podName(sessionID))
}
func (s *SandboxManager) deleteService(ctx context.Context, sessionID string) error {
return s.k8sDelete(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/services/"+svcName(sessionID))
}
func (s *SandboxManager) deletePVC(ctx context.Context, sessionID string) error {
return s.k8sDelete(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/persistentvolumeclaims/"+pvcName(sessionID))
}
func (s *SandboxManager) listSandboxPods(ctx context.Context) ([]any, error) {
body, err := s.k8sGet(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods?labelSelector=app%3Dpi-session")
if err != nil {
return nil, err
}
items, _ := body["items"].([]any)
return items, nil
}
// ── raw k8s HTTP ─────────────────────────────────────────────────────────
func (s *SandboxManager) k8sGet(ctx context.Context, path string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.k8sHost+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+sandboxSAToken())
resp, err := s.k8sClient.Do(req)
if err != nil {
return nil, fmt.Errorf("k8s GET %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, &k8sNotFound{path: path}
}
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("k8s GET %s returned %d: %s", path, resp.StatusCode, b)
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return result, nil
}
func (s *SandboxManager) k8sCreate(ctx context.Context, path string, body any) error {
payload, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.k8sHost+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+sandboxSAToken())
req.Header.Set("Content-Type", "application/json")
resp, err := s.k8sClient.Do(req)
if err != nil {
return fmt.Errorf("k8s POST %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode == 409 { // AlreadyExists — idempotent
return nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("k8s POST %s returned %d: %s", path, resp.StatusCode, b)
}
return nil
}
func (s *SandboxManager) k8sDelete(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.k8sHost+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+sandboxSAToken())
resp, err := s.k8sClient.Do(req)
if err != nil {
return fmt.Errorf("k8s DELETE %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return &k8sNotFound{path: path}
}
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("k8s DELETE %s returned %d: %s", path, resp.StatusCode, b)
}
return nil
}
// ── error helpers ─────────────────────────────────────────────────────────
type k8sNotFound struct{ path string }
func (e *k8sNotFound) Error() string { return "not found: " + e.path }
func isNotFound(err error) bool {
if err == nil {
return false
}
_, ok := err.(*k8sNotFound)
return ok
}
func toSlice(v any) []any {
if s, ok := v.([]any); ok {
return s
}
return nil
}
-271
View File
@@ -1,271 +0,0 @@
package sessions
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// ── helpers ───────────────────────────────────────────────────────────────────
// runningReadyPod returns minimal k8s pod JSON: phase Running + Ready condition.
func runningReadyPod(name string) map[string]any {
return map[string]any{
"metadata": map[string]any{"name": name},
"status": map[string]any{
"phase": "Running",
"conditions": []any{
map[string]any{"type": "Ready", "status": "True"},
},
},
}
}
// respondK8sJSON writes a JSON body with the given status code.
func respondK8sJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(body) //nolint:errcheck
}
// newTestSM builds a SandboxManager pointed at the given test server URL.
func newTestSM(url string) *SandboxManager {
return &SandboxManager{
k8sHost: url,
k8sClient: &http.Client{Timeout: 5 * time.Second},
pvcSize: "1Gi",
readyTimeout: 3 * time.Second,
}
}
// ── TestSandboxCreateOrResume_CreateNew ───────────────────────────────────────
// No existing resources → creates PVC, NetworkPolicy, Pod, Service; Resumed=false.
func TestSandboxCreateOrResume_CreateNew(t *testing.T) {
id := "newid"
var podGetCount int32
mux := http.NewServeMux()
// PVC: GET → 404, POST → 201
mux.HandleFunc("GET /api/v1/namespaces/poimen/persistentvolumeclaims/{name}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("POST /api/v1/namespaces/poimen/persistentvolumeclaims", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
})
// NetworkPolicy (fixed name "pi-session-sandbox"): GET → 404, POST → 201
mux.HandleFunc("GET /apis/networking.k8s.io/v1/namespaces/poimen/networkpolicies/{name}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("POST /apis/networking.k8s.io/v1/namespaces/poimen/networkpolicies", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
})
// Pod: first GET (getPodPhase) → 404; second GET (waitPodReady) → Running+Ready; POST → 201
mux.HandleFunc("GET /api/v1/namespaces/poimen/pods/{name}", func(w http.ResponseWriter, _ *http.Request) {
if atomic.AddInt32(&podGetCount, 1) == 1 {
w.WriteHeader(http.StatusNotFound)
return
}
respondK8sJSON(w, http.StatusOK, runningReadyPod(podName(id)))
})
mux.HandleFunc("POST /api/v1/namespaces/poimen/pods", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
})
// Service: GET → 404, POST → 201
mux.HandleFunc("GET /api/v1/namespaces/poimen/services/{name}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("POST /api/v1/namespaces/poimen/services", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
})
srv := httptest.NewServer(mux)
defer srv.Close()
result, err := newTestSM(srv.URL).CreateOrResume(context.Background(), id)
if err != nil {
t.Fatalf("CreateOrResume: %v", err)
}
if want := svcDNS(id); result.SvcDNS != want {
t.Errorf("SvcDNS: want %q, got %q", want, result.SvcDNS)
}
if result.Resumed {
t.Error("want Resumed=false, got true")
}
}
// ── TestSandboxCreateOrResume_Resume ─────────────────────────────────────────
// PVC exists + pod Running → Resumed=true, no POST calls.
func TestSandboxCreateOrResume_Resume(t *testing.T) {
id := "resumeid"
mux := http.NewServeMux()
// PVC exists
mux.HandleFunc("GET /api/v1/namespaces/poimen/persistentvolumeclaims/{name}", func(w http.ResponseWriter, _ *http.Request) {
respondK8sJSON(w, http.StatusOK, map[string]any{"metadata": map[string]any{"name": pvcName(id)}})
})
// Pod exists and Running (getPodPhase uses GET /{name})
mux.HandleFunc("GET /api/v1/namespaces/poimen/pods/{name}", func(w http.ResponseWriter, _ *http.Request) {
respondK8sJSON(w, http.StatusOK, map[string]any{
"metadata": map[string]any{"name": podName(id)},
"status": map[string]any{"phase": "Running"},
})
})
// Any POST → 500 to assert no creates are issued
mux.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected POST to %s", r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
})
srv := httptest.NewServer(mux)
defer srv.Close()
result, err := newTestSM(srv.URL).CreateOrResume(context.Background(), id)
if err != nil {
t.Fatalf("CreateOrResume: %v", err)
}
if !result.Resumed {
t.Error("want Resumed=true, got false")
}
if want := svcDNS(id); result.SvcDNS != want {
t.Errorf("SvcDNS: want %q, got %q", want, result.SvcDNS)
}
}
// ── TestSandboxDelete ─────────────────────────────────────────────────────────
// All DELETEs succeed → no error returned.
func TestSandboxDelete(t *testing.T) {
id := "deleteid"
mux := http.NewServeMux()
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/pods/{name}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/services/{name}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/persistentvolumeclaims/{name}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
defer srv.Close()
if err := newTestSM(srv.URL).Delete(context.Background(), id); err != nil {
t.Errorf("Delete: %v", err)
}
}
// ── TestSandboxDelete_NotFound ────────────────────────────────────────────────
// All DELETEs return 404 → isNotFound errors swallowed → no error returned.
func TestSandboxDelete_NotFound(t *testing.T) {
id := "ghostid"
mux := http.NewServeMux()
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/pods/{name}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/services/{name}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/persistentvolumeclaims/{name}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
srv := httptest.NewServer(mux)
defer srv.Close()
if err := newTestSM(srv.URL).Delete(context.Background(), id); err != nil {
t.Errorf("Delete_NotFound: want nil, got %v", err)
}
}
// ── TestSandboxReapIdlePods ───────────────────────────────────────────────────
// pod-a=Succeeded → deleted; pod-b=Running → skipped.
func TestSandboxReapIdlePods(t *testing.T) {
var deletedA int32
mux := http.NewServeMux()
// List all pi-session pods (query string not part of mux path matching)
mux.HandleFunc("GET /api/v1/namespaces/poimen/pods", func(w http.ResponseWriter, _ *http.Request) {
respondK8sJSON(w, http.StatusOK, map[string]any{
"items": []any{
map[string]any{
"metadata": map[string]any{"name": "pod-a"},
"status": map[string]any{"phase": "Succeeded"},
},
map[string]any{
"metadata": map[string]any{"name": "pod-b"},
"status": map[string]any{"phase": "Running"},
},
},
})
})
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/pods/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "pod-b" {
t.Errorf("pod-b (Running) must NOT be deleted")
}
if name == "pod-a" {
atomic.AddInt32(&deletedA, 1)
}
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
defer srv.Close()
newTestSM(srv.URL).ReapIdlePods(context.Background())
if got := atomic.LoadInt32(&deletedA); got != 1 {
t.Errorf("pod-a delete count: want 1, got %d", got)
}
}
// ── TestSandboxNameHelpers ────────────────────────────────────────────────────
func TestSandboxNameHelpers(t *testing.T) {
cases := []struct{ fn, want, got string }{
{"pvcName", "workspace-session-abc", pvcName("abc")},
{"podName", "pi-session-abc", podName("abc")},
{"svcName", "pi-session-abc", svcName("abc")},
{"svcDNS", "pi-session-abc.poimen.svc.cluster.local:8787", svcDNS("abc")},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("%s: want %q, got %q", c.fn, c.want, c.got)
}
}
}
// ── TestSandboxIsNotFound ─────────────────────────────────────────────────────
func TestSandboxIsNotFound(t *testing.T) {
if !isNotFound(&k8sNotFound{}) {
t.Error("isNotFound(&k8sNotFound{}) must be true")
}
if isNotFound(fmt.Errorf("other")) {
t.Error("isNotFound(fmt.Errorf) must be false")
}
if isNotFound(nil) {
t.Error("isNotFound(nil) must be false")
}
}
File diff suppressed because it is too large Load Diff
+95
View File
@@ -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
}
-329
View File
@@ -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
}
-224
View File
@@ -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()
}
-302
View File
@@ -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 ""
}
-424
View File
@@ -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
}
-408
View File
@@ -1,408 +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"`) {
t.Errorf("patch path missing: %s", raw)
}
if !strings.Contains(string(raw), `"images"`) {
t.Errorf("images key missing from kustomize block: %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)
}
-27
View File
@@ -76,33 +76,6 @@ spec:
value: "1.0.0"
- name: OTEL_ENVIRONMENT
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:
- name: config
mountPath: /etc/gateway
-321
View File
@@ -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
}
+18 -11
View File
@@ -5,32 +5,35 @@ metadata:
namespace: api
spec:
template:
metadata:
labels:
app: api-gateway
managed-by: test
role: integration-test
spec:
serviceAccountName: api-gateway
restartPolicy: Never
containers:
- name: integration-tester
image: golang:1.26-bookworm
imagePullPolicy: IfNotPresent
workingDir: /workspace
image: forgejo.riotpiao.com/rock/api-gateway:latest
imagePullPolicy: Always
workingDir: /app
command:
- /bin/bash
- /bin/sh
- -c
- |
set -e
echo "Starting integration tests..."
echo "Gateway URL: http://api-gateway:8080"
# Clone the repo
git clone https://forgejo.riotpiao.com/riotpiao-poimen/homelab-frontend.git .
# Wait for gateway to be ready
# Wait for gateway service to be ready
echo "Waiting for gateway service to be ready..."
for i in {1..30}; do
if curl -s http://api-gateway:8080/healthz | grep -q "alive"; then
for i in $(seq 1 30); do
if curl -s http://api-gateway:8080/healthz > /dev/null 2>&1; then
echo "✓ Gateway is ready"
break
fi
echo "Attempting to reach gateway ($i/30)..."
echo "Waiting for gateway... ($i/30)"
sleep 2
done
@@ -42,6 +45,8 @@ spec:
env:
- name: GATEWAY_URL
value: "http://api-gateway:8080"
- name: CI
value: "true"
resources:
requests:
cpu: 250m
@@ -67,4 +72,6 @@ spec:
emptyDir: {}
- name: home
emptyDir: {}
imagePullSecrets:
- name: regcred
backoffLimit: 1
-1
View File
@@ -9,7 +9,6 @@ resources:
- deployment.yaml
- network-policy.yaml
- gateway-config-secret.yaml
- rbac.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.
-27
View File
@@ -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
+3 -52
View File
@@ -21,17 +21,11 @@ spec:
ports:
- protocol: TCP
port: 8080
# Allow from Prometheus scraping (monitoring namespace)
# MUST select on kubernetes.io/metadata.name, not a plain `name:` label.
# Only ingress-nginx carries a plain `name` label in this cluster; every
# other namespace has just kubernetes.io/metadata.name, so `name: monitoring`
# matched nothing and silently denied every Prometheus scrape of :8080
# (surfaced as context-deadline-exceeded on all 3 gateway replicas while the
# ServiceMonitor itself was discovered and healthy).
# Allow from Prometheus scraping (if in monitoring namespace)
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
name: monitoring
ports:
- protocol: TCP
port: 8080
@@ -52,14 +46,6 @@ spec:
ports:
- protocol: TCP
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:
# Allow DNS
- to:
@@ -128,7 +114,7 @@ spec:
ports:
- protocol: TCP
port: 7233
# Allow to Poimen (memory:8080 + graphql:8090 + redis:6379 + fleet-controller:8788)
# Allow to Poimen (memory/semantic search)
- to:
- namespaceSelector:
matchLabels:
@@ -136,20 +122,6 @@ spec:
ports:
- protocol: TCP
port: 8080
- protocol: TCP
port: 8090
- protocol: TCP
port: 6379
- protocol: TCP
port: 8788
# Allow to Gotify (push notifications)
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: notifications
ports:
- protocol: TCP
port: 80
# Allow to MinIO (S3-compatible storage)
# Service `minio` listens on port 80 (targetPort 9000).
# Headless `minio-cluster-hl` is 9000. Allow both.
@@ -162,24 +134,3 @@ spec:
port: 80
- protocol: TCP
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
-62
View File
@@ -1,62 +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
---
# Role + RoleBinding for pi-session sandbox manager (§4.15.4).
# api-gateway SA needs get/create/delete for pods, pvcs, services,
# and networkpolicies in the poimen namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: session-sandbox-manager
namespace: poimen
labels:
app: api-gateway
rules:
- apiGroups: [""]
resources: ["pods", "persistentvolumeclaims", "services"]
verbs: ["get", "list", "create", "delete"]
- apiGroups: ["networking.k8s.io"]
resources: ["networkpolicies"]
verbs: ["get", "create", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: session-sandbox-manager-api-gateway
namespace: poimen
labels:
app: api-gateway
subjects:
- kind: ServiceAccount
name: api-gateway
namespace: api
roleRef:
kind: Role
name: session-sandbox-manager
apiGroup: rbac.authorization.k8s.io
-29
View File
@@ -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
-48
View File
@@ -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
-25
View File
@@ -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
-124
View File
@@ -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)
-127
View File
@@ -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 ]
-224
View File
@@ -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 ]
-75
View File
@@ -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
-101
View File
@@ -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
-84
View File
@@ -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