Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2c085b688 |
@@ -60,7 +60,7 @@ jobs:
|
||||
# (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
|
||||
if: false # SKIP: only needed for canary deploy
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${KUBECONFIG_B64}" | base64 -d > ~/.kube/config
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
# ── 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
|
||||
# TODO: Re-enable k8s rolling update when kubeconfig/RBAC is ready
|
||||
# - name: Promote — rolling update
|
||||
# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# run: |
|
||||
|
||||
@@ -12,7 +12,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"
|
||||
@@ -113,14 +112,6 @@ func main() {
|
||||
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 {
|
||||
@@ -144,7 +135,6 @@ func main() {
|
||||
// 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)
|
||||
|
||||
@@ -33,11 +33,7 @@ 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.
|
||||
@@ -120,18 +116,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"
|
||||
|
||||
@@ -39,7 +39,6 @@ type rawRoute struct {
|
||||
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"`
|
||||
@@ -122,18 +121,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 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 rawModel.Address == "" {
|
||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
|
||||
}
|
||||
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
|
||||
@@ -142,8 +134,7 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
|
||||
}
|
||||
models[rawModel.Name] = &ModelUpstream{
|
||||
Name: rawModel.Name,
|
||||
Address: addresses[0],
|
||||
Addresses: addresses,
|
||||
Address: rawModel.Address,
|
||||
Path: rawModel.Path,
|
||||
UpstreamModel: rawModel.UpstreamModel,
|
||||
AuthRequired: authRequired,
|
||||
|
||||
@@ -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
|
||||
}())
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -36,11 +36,6 @@ type Metrics struct {
|
||||
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.
|
||||
@@ -285,61 +280,4 @@ func (m *Metrics) Reset() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -36,18 +36,6 @@ func (p *PrometheusExporter) Export() string {
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
+91
-286
@@ -2,20 +2,15 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -23,7 +18,6 @@ import (
|
||||
"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,18 +37,11 @@ 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)
|
||||
Transport *http.Transport
|
||||
@@ -106,47 +93,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 +116,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
|
||||
}
|
||||
|
||||
@@ -228,7 +165,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,7 +251,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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -324,166 +271,17 @@ 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 {
|
||||
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")
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// applyWarmup strips the X-Poimen-Warmup header, delays the request while
|
||||
// honouring context cancellation, and records the metric.
|
||||
// Returns false if the context was cancelled during the delay (caller must abort).
|
||||
func (h *Handler) applyWarmup(ctx context.Context, r *http.Request) bool {
|
||||
if r.Header.Get("X-Poimen-Warmup") != "true" {
|
||||
return true
|
||||
}
|
||||
r.Header.Del("X-Poimen-Warmup")
|
||||
r.Header.Set("X-Priority", "low")
|
||||
select {
|
||||
case <-time.After(h.warmupDelay):
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
observability.Global().IncrWarmupDelayed()
|
||||
return true
|
||||
}
|
||||
|
||||
// acquireScheduledBackend selects a backend from sched, applies warmup
|
||||
// deprioritisation, acquires a concurrency slot, and patches route when the
|
||||
// selected backend differs from the config default. It returns the backend
|
||||
// to Release (caller must defer), the (possibly patched) route, and ok=true.
|
||||
// If the slot cannot be acquired it writes a 503 and returns (nil, nil, false).
|
||||
func (h *Handler) acquireScheduledBackend(ctx context.Context, w http.ResponseWriter, r *http.Request, sched *Scheduler, route *Route, cacheKey string) (*Backend, *Route, bool) {
|
||||
backend := sched.Select(cacheKey)
|
||||
if !h.applyWarmup(ctx, r) {
|
||||
return nil, nil, false
|
||||
}
|
||||
if backend == nil {
|
||||
return nil, route, true
|
||||
}
|
||||
if err := backend.Acquire(ctx); err != nil {
|
||||
if errors.Is(err, ErrQueueFull) {
|
||||
observability.Global().IncrQueueFull()
|
||||
logging.Warnf("backend queue full", map[string]string{
|
||||
"backend": backend.Address,
|
||||
"path": r.URL.Path,
|
||||
"model": route.ModelName,
|
||||
})
|
||||
w.Header().Set("Retry-After", "5")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
fmt.Fprintf(w, "queue full")
|
||||
} else {
|
||||
logging.Warnf("backend acquire failed", map[string]string{
|
||||
"backend": backend.Address,
|
||||
"path": r.URL.Path,
|
||||
"model": route.ModelName,
|
||||
"err": err.Error(),
|
||||
})
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
fmt.Fprintf(w, "upstream busy")
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
if backend.Address != route.Upstream.Address {
|
||||
patchedCfg := *route.Upstream
|
||||
patchedCfg.Address = backend.Address
|
||||
patchedURL, _ := url.Parse("http://" + backend.Address)
|
||||
route = &Route{
|
||||
Name: route.Name,
|
||||
ModelName: route.ModelName,
|
||||
Upstream: &patchedCfg,
|
||||
Transport: h.getOrCreateTransport(backend.Address, &patchedCfg),
|
||||
Director: func(req *http.Request) {
|
||||
directorFunc(req, patchedURL, &patchedCfg)
|
||||
},
|
||||
}
|
||||
}
|
||||
return backend, route, true
|
||||
}
|
||||
|
||||
// handleAuthEndpoint routes /auth/* requests and returns true if handled.
|
||||
func (h *Handler) handleAuthEndpoint(w http.ResponseWriter, r *http.Request) bool {
|
||||
switch r.URL.Path {
|
||||
case "/auth/token":
|
||||
h.handleAuthToken(w, r)
|
||||
return true
|
||||
case "/auth/refresh":
|
||||
h.handleAuthRefresh(w, r)
|
||||
return true
|
||||
case "/auth/exchange":
|
||||
h.handleAuthExchange(w, r)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// routeOrError resolves a route for r, writing error responses on failure.
|
||||
// Returns (route, true) on success or (nil, false) when a response has been written.
|
||||
func (h *Handler) routeOrError(w http.ResponseWriter, r *http.Request) (*Route, bool) {
|
||||
// Try to find a matching route (including body-based dispatch for /v1/chat/completions).
|
||||
// Note: /workflows endpoint is deprecated. Use X-Service: workflow + X-Resource headers instead.
|
||||
route, err := h.RouteRequest(r)
|
||||
if validationErr, ok := err.(*modelValidationError); ok {
|
||||
h.writeModelValidationError(w, validationErr, h.getValidModels())
|
||||
logging.Errorf("client error", validationErr, map[string]string{
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
"reason": validationErr.Kind,
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
if err != nil || route == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, "not found")
|
||||
if err != nil {
|
||||
logging.Errorf("routing failed", err, map[string]string{
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
})
|
||||
} else {
|
||||
logging.Errorf("no route matches", fmt.Errorf("path=%s method=%s", r.URL.Path, r.Method), nil)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return route, true
|
||||
}
|
||||
|
||||
// writeModelValidationError writes a problem-detail response for model validation errors.
|
||||
func (h *Handler) writeModelValidationError(w http.ResponseWriter, validationErr *modelValidationError, validModels []string) {
|
||||
// Check if this is a model validation error (from body-based dispatch)
|
||||
if validationErr, ok := err.(*modelValidationError); ok {
|
||||
// This is a client error, not a routing error
|
||||
var status int
|
||||
var problemType, title, detail string
|
||||
var models []string
|
||||
var problemType string
|
||||
var title string
|
||||
var detail string
|
||||
|
||||
switch validationErr.Kind {
|
||||
case "invalid_json":
|
||||
@@ -496,13 +294,11 @@ func (h *Handler) writeModelValidationError(w http.ResponseWriter, validationErr
|
||||
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"
|
||||
@@ -510,19 +306,42 @@ func (h *Handler) writeModelValidationError(w http.ResponseWriter, validationErr
|
||||
detail = validationErr.Message
|
||||
}
|
||||
|
||||
writeProblemDetail(w, status, problemType, title, detail, models)
|
||||
// Get list of valid models (only for model-related errors)
|
||||
var validModels []string
|
||||
if validationErr.Kind == "unknown_model" || validationErr.Kind == "missing_model" || validationErr.Kind == "empty_model" || validationErr.Kind == "null_model" {
|
||||
validModels = h.getValidModels()
|
||||
}
|
||||
|
||||
// authenticateV1 validates JWT for /v1/* paths.
|
||||
// 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
|
||||
writeProblemDetail(w, status, problemType, title, detail, validModels)
|
||||
logging.Errorf("client error", validationErr, map[string]string{
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
"reason": validationErr.Kind,
|
||||
})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||
return true
|
||||
|
||||
if err != nil || route == nil {
|
||||
// Route not found or error determining route
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, "not found")
|
||||
if err != nil {
|
||||
logging.Errorf("routing failed", err, map[string]string{
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
})
|
||||
} else {
|
||||
logging.Errorf("no route matches", fmt.Errorf("path=%s method=%s", r.URL.Path, r.Method), nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Strip spoofed identity headers from all inbound requests.
|
||||
// Must happen before any routing — even unauthenticated paths.
|
||||
identity.StripIncoming(r)
|
||||
|
||||
// JWT Authentication for /v1/* endpoints
|
||||
if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
writeProblemDetail(w, http.StatusUnauthorized,
|
||||
@@ -533,8 +352,9 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
||||
logging.Errorf("auth failed", fmt.Errorf("missing auth header"), map[string]string{
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.jwtValidator.ValidateBearerToken(authHeader)
|
||||
if err != nil {
|
||||
writeProblemDetail(w, http.StatusForbidden,
|
||||
@@ -545,9 +365,13 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
||||
logging.Errorf("auth failed", err, map[string]string{
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -559,18 +383,17 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
||||
"path": r.URL.Path,
|
||||
"required": h.config.Auth.RequiredCapability,
|
||||
})
|
||||
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
|
||||
}
|
||||
// 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")
|
||||
@@ -581,72 +404,54 @@ func (h *Handler) enforceBodyLimit(w http.ResponseWriter, r *http.Request, route
|
||||
"content_length": fmt.Sprintf("%d", r.ContentLength),
|
||||
"max_body_size": fmt.Sprintf("%d", route.Upstream.MaxBodySize),
|
||||
})
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
// Wrap request body with size limiter
|
||||
// This enforces the cap at read time, not after buffering
|
||||
if route.Upstream.MaxBodySize > 0 && r.Body != nil {
|
||||
r.Body = io.NopCloser(io.LimitReader(r.Body, route.Upstream.MaxBodySize))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// setupCAGProxy acquires a scheduled backend (if any), builds a reverse proxy,
|
||||
// wires ErrorHandler and ModifyResponse, and returns (proxy, backend, true).
|
||||
// Returns (nil, nil, false) when the backend slot cannot be acquired (response written).
|
||||
func (h *Handler) setupCAGProxy(ctx context.Context, w http.ResponseWriter, r *http.Request, route *Route, sched *Scheduler, cacheKey string) (*httputil.ReverseProxy, *Backend, bool) {
|
||||
var selectedBackend *Backend
|
||||
if sched != nil {
|
||||
var ok bool
|
||||
selectedBackend, route, ok = h.acquireScheduledBackend(ctx, w, r, sched, route, cacheKey)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// Create the reverse proxy
|
||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||
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.
|
||||
|
||||
// For streaming responses (SSE, chunked), disable buffering to ensure events
|
||||
// reach clients immediately. Issue #33: X-Accel-Buffering:no tells nginx/Ingress
|
||||
// to stream instead of buffer. ResponseController.Flush() in upstream handler
|
||||
// pairs with this to deliver unbuffered chunks.
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
// Serve the request through the proxy
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
return proxy, selectedBackend, true
|
||||
}
|
||||
|
||||
|
||||
// getValidModels returns a sorted list of all configured model names.
|
||||
func (h *Handler) getValidModels() []string {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,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) {
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrQueueFull is returned when the in-memory queue is at capacity.
|
||||
var ErrQueueFull = errors.New("backend queue full")
|
||||
|
||||
// Backend represents a single vLLM upstream with a bounded in-memory queue.
|
||||
// Two channels model the queue:
|
||||
//
|
||||
// queue — waiting requests (bounded by VLLM_MAX_QUEUE_DEPTH)
|
||||
// slots — concurrent in-service requests (bounded by VLLM_MAX_NUM_SEQS)
|
||||
type Backend struct {
|
||||
Address string
|
||||
queue chan struct{}
|
||||
slots chan struct{}
|
||||
}
|
||||
|
||||
// newBackend creates a Backend with given queue depth and concurrency limit.
|
||||
func newBackend(addr string, maxSlots, maxQueue int) *Backend {
|
||||
slots := make(chan struct{}, maxSlots)
|
||||
for i := 0; i < maxSlots; i++ {
|
||||
slots <- struct{}{}
|
||||
}
|
||||
return &Backend{
|
||||
Address: addr,
|
||||
queue: make(chan struct{}, maxQueue),
|
||||
slots: slots,
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire enters the in-memory queue then waits for a vLLM concurrency slot.
|
||||
// Returns ErrQueueFull immediately if the queue channel is at capacity (no goroutine queued).
|
||||
// Returns ctx.Err() if the context is cancelled while waiting for a slot.
|
||||
func (b *Backend) Acquire(ctx context.Context) error {
|
||||
// Non-blocking queue entry: reject immediately when queue is full.
|
||||
select {
|
||||
case b.queue <- struct{}{}:
|
||||
// entered queue
|
||||
default:
|
||||
return ErrQueueFull
|
||||
}
|
||||
// Wait for a concurrency slot, then leave the queue.
|
||||
select {
|
||||
case <-b.slots:
|
||||
<-b.queue // leave queue; now in service
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
<-b.queue // leave queue; cancelled
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Release returns a concurrency slot to the pool after the upstream request completes.
|
||||
func (b *Backend) Release() {
|
||||
b.slots <- struct{}{}
|
||||
}
|
||||
|
||||
// Scheduler routes LLM requests to backends using rendezvous hashing (cache-aware)
|
||||
// or round-robin (no cache key). Failed backends are excluded for a configurable window.
|
||||
type Scheduler struct {
|
||||
mu sync.RWMutex
|
||||
backends []*Backend
|
||||
failedUntil map[string]time.Time
|
||||
failWindow time.Duration
|
||||
rrIdx atomic.Uint64
|
||||
}
|
||||
|
||||
// NewScheduler creates a Scheduler for the given addresses.
|
||||
// maxSlots = VLLM_MAX_NUM_SEQS, maxQueue = VLLM_MAX_QUEUE_DEPTH.
|
||||
func NewScheduler(addresses []string, maxSlots, maxQueue int) *Scheduler {
|
||||
backends := make([]*Backend, len(addresses))
|
||||
for i, addr := range addresses {
|
||||
backends[i] = newBackend(addr, maxSlots, maxQueue)
|
||||
}
|
||||
return &Scheduler{
|
||||
backends: backends,
|
||||
failedUntil: make(map[string]time.Time),
|
||||
failWindow: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Select returns a backend. Uses rendezvous hashing when cacheKey is non-empty
|
||||
// (same key always routes to the same backend, skipping unhealthy ones).
|
||||
// Uses round-robin when cacheKey is empty.
|
||||
func (s *Scheduler) Select(cacheKey string) *Backend {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if len(s.backends) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(s.backends) == 1 {
|
||||
return s.backends[0]
|
||||
}
|
||||
if cacheKey != "" {
|
||||
return s.selectRendezvous(cacheKey)
|
||||
}
|
||||
return s.selectRoundRobin()
|
||||
}
|
||||
|
||||
// selectRendezvous picks the healthy backend with the highest FNV32a score
|
||||
// for the key (highest random weight / rendezvous hashing).
|
||||
// Falls back to next-highest score when primary is excluded by MarkFailed.
|
||||
// Must be called with mu held.
|
||||
func (s *Scheduler) selectRendezvous(cacheKey string) *Backend {
|
||||
type scored struct {
|
||||
b *Backend
|
||||
score uint32
|
||||
}
|
||||
now := time.Now()
|
||||
scores := make([]scored, len(s.backends))
|
||||
h := fnv.New32a()
|
||||
for i, b := range s.backends {
|
||||
h.Reset()
|
||||
h.Write([]byte(cacheKey + ":" + b.Address))
|
||||
scores[i] = scored{b: b, score: h.Sum32()}
|
||||
}
|
||||
sort.Slice(scores, func(i, j int) bool {
|
||||
return scores[i].score > scores[j].score
|
||||
})
|
||||
for _, sc := range scores {
|
||||
_, failed := s.failedUntil[sc.b.Address]
|
||||
if !failed {
|
||||
return sc.b
|
||||
}
|
||||
expiry := s.failedUntil[sc.b.Address]
|
||||
if now.After(expiry) {
|
||||
return sc.b
|
||||
}
|
||||
}
|
||||
// All backends unhealthy: return highest-scored (fail-open).
|
||||
return scores[0].b
|
||||
}
|
||||
|
||||
// selectRoundRobin distributes requests evenly when no cache key is present.
|
||||
// Must be called with mu held.
|
||||
func (s *Scheduler) selectRoundRobin() *Backend {
|
||||
idx := s.rrIdx.Add(1) - 1
|
||||
return s.backends[idx%uint64(len(s.backends))]
|
||||
}
|
||||
|
||||
// MarkFailed excludes addr from routing for the fail window (default 30 s).
|
||||
// Called by the proxy ErrorHandler on upstream connection failure.
|
||||
func (s *Scheduler) MarkFailed(addr string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failedUntil[addr] = time.Now().Add(s.failWindow)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestBackendAcquireBlocksThenUnblocksOnRelease verifies that a second Acquire
|
||||
// blocks while a slot is held, then unblocks when Release is called.
|
||||
func TestBackendAcquireBlocksThenUnblocksOnRelease(t *testing.T) {
|
||||
b := newBackend("host:1", 1, 1)
|
||||
|
||||
// G1: acquire and hold the only slot.
|
||||
if err := b.Acquire(context.Background()); err != nil {
|
||||
t.Fatalf("G1 Acquire: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
g2Done := make(chan error, 1)
|
||||
go func() {
|
||||
g2Done <- b.Acquire(context.Background())
|
||||
}()
|
||||
|
||||
// Wait until G2 enters the queue channel (proves Acquire is blocking on slots).
|
||||
for len(b.queue) == 0 {
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-g2Done:
|
||||
t.Fatalf("G2 returned before Release: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
// G1 releases; G2 should unblock.
|
||||
b.Release()
|
||||
|
||||
select {
|
||||
case err := <-g2Done:
|
||||
if err != nil {
|
||||
t.Fatalf("G2 Acquire after Release: unexpected error: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("G2 did not unblock within 1s after Release")
|
||||
}
|
||||
|
||||
// Cleanup: release G2's slot.
|
||||
b.Release()
|
||||
}
|
||||
|
||||
// TestBackendAcquireReturnsErrQueueFullWhenQueueFull verifies that Acquire returns
|
||||
// ErrQueueFull immediately when no queue capacity exists (maxQueue=0).
|
||||
// With a zero-capacity queue channel the non-blocking select always hits default.
|
||||
func TestBackendAcquireReturnsErrQueueFullWhenQueueFull(t *testing.T) {
|
||||
b := newBackend("host:1", 1, 0)
|
||||
|
||||
err := b.Acquire(context.Background())
|
||||
if !errors.Is(err, ErrQueueFull) {
|
||||
t.Fatalf("expected ErrQueueFull, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchedulerSelectRendezvousHashDeterministic verifies that the same cache key
|
||||
// always selects the same backend (rendezvous / highest-random-weight hashing).
|
||||
func TestSchedulerSelectRendezvousHashDeterministic(t *testing.T) {
|
||||
addrs := []string{"a:80", "b:80", "c:80"}
|
||||
s := NewScheduler(addrs, 10, 10)
|
||||
|
||||
first := s.Select("same-cache-key")
|
||||
if first == nil {
|
||||
t.Fatal("Select returned nil")
|
||||
}
|
||||
for i := 1; i < 10; i++ {
|
||||
b := s.Select("same-cache-key")
|
||||
if b == nil || b.Address != first.Address {
|
||||
t.Fatalf("call %d: want %s, got %v", i, first.Address, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchedulerSelectNoKeyRoundRobin verifies that round-robin distributes
|
||||
// requests evenly across all backends when no cache key is given.
|
||||
func TestSchedulerSelectNoKeyRoundRobin(t *testing.T) {
|
||||
addrs := []string{"a:80", "b:80", "c:80"}
|
||||
s := NewScheduler(addrs, 10, 10)
|
||||
|
||||
counts := make(map[string]int)
|
||||
for i := 0; i < 9; i++ {
|
||||
b := s.Select("")
|
||||
if b == nil {
|
||||
t.Fatalf("call %d: Select returned nil", i)
|
||||
}
|
||||
counts[b.Address]++
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if counts[addr] != 3 {
|
||||
t.Errorf("addr %s: want 3 selections, got %d (all counts: %v)", addr, counts[addr], counts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackendAcquireCancelledContext verifies that Acquire returns ctx.Err() when the
|
||||
// context is cancelled while the caller is blocked waiting for a concurrency slot,
|
||||
// and that the queue depth returns to 0 after cancellation.
|
||||
func TestBackendAcquireCancelledContext(t *testing.T) {
|
||||
b := newBackend("host:1", 1, 1)
|
||||
|
||||
// G1: acquire and hold the only slot indefinitely.
|
||||
if err := b.Acquire(context.Background()); err != nil {
|
||||
t.Fatalf("G1 Acquire: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
g2Done := make(chan error, 1)
|
||||
go func() {
|
||||
g2Done <- b.Acquire(ctx)
|
||||
}()
|
||||
|
||||
// Wait until G2 enters the queue channel.
|
||||
for len(b.queue) == 0 {
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
// Verify G2 is queued (queue channel has one item).
|
||||
if qd := len(b.queue); qd != 1 {
|
||||
t.Errorf("expected queue depth 1 while G2 blocked, got %d", qd)
|
||||
}
|
||||
|
||||
// Cancel ctx while G2 is waiting for the slot.
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-g2Done:
|
||||
if err == nil {
|
||||
t.Fatal("G2 Acquire: expected ctx.Err(), got nil")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("G2 Acquire: expected context.Canceled, got %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("G2 did not return within 1s after context cancel")
|
||||
}
|
||||
|
||||
// Queue depth must return to 0 after G2 is drained.
|
||||
if qd := len(b.queue); qd != 0 {
|
||||
t.Errorf("expected queue depth 0 after cancellation, got %d", qd)
|
||||
}
|
||||
|
||||
// Cleanup: release G1's slot.
|
||||
b.Release()
|
||||
}
|
||||
|
||||
// TestSchedulerMarkFailedExcludesBackend verifies that a failed backend is not
|
||||
// selected within the fail window (30 s default).
|
||||
func TestSchedulerMarkFailedExcludesBackend(t *testing.T) {
|
||||
addrs := []string{"a:80", "b:80"}
|
||||
s := NewScheduler(addrs, 10, 10)
|
||||
|
||||
s.MarkFailed("a:80")
|
||||
|
||||
var mu sync.Mutex
|
||||
seen := make(map[string]int)
|
||||
for i := 0; i < 10; i++ {
|
||||
b := s.Select("any-key")
|
||||
if b == nil {
|
||||
t.Fatalf("call %d: Select returned nil", i)
|
||||
}
|
||||
mu.Lock()
|
||||
seen[b.Address]++
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
if seen["a:80"] > 0 {
|
||||
t.Errorf("failed backend a:80 was selected %d times; expected 0 within fail window", seen["a:80"])
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ type Router struct {
|
||||
temporalHandler http.Handler
|
||||
upstreamHandler http.Handler
|
||||
forgejoWebhook *webhook.ForgejoHandler
|
||||
metricsHandler http.Handler
|
||||
}
|
||||
|
||||
// NewRouter creates a new router with health endpoints.
|
||||
@@ -34,11 +33,6 @@ func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatch
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +48,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
|
||||
|
||||
@@ -28,9 +28,6 @@ func AdapterFactory(serviceName string) interface{} {
|
||||
return &IAMAdapter{}
|
||||
case "memory":
|
||||
return &MemoryAdapter{}
|
||||
case "sessions":
|
||||
// code-registered; see NewSessionAdapter()
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package serviceadapter
|
||||
|
||||
import "os"
|
||||
|
||||
// NewMemoryAdapter creates a code-registered ServiceAdapter for the memory service.
|
||||
// Takes precedence over any config-file loaded memory adapter (main.go skips config
|
||||
// adapters when a code-registered one with the same name exists).
|
||||
// Upstream URL configured via MEMORY_UPSTREAM_URL env var.
|
||||
func NewMemoryAdapter() *ServiceAdapter {
|
||||
upstreamURL := os.Getenv("MEMORY_UPSTREAM_URL")
|
||||
if upstreamURL == "" {
|
||||
upstreamURL = "http://poimen-memory.poimen.svc.cluster.local:8080"
|
||||
}
|
||||
return &ServiceAdapter{
|
||||
Namespace: "poimen",
|
||||
ServiceName: "memory",
|
||||
Spec: *GetMemorySpec(upstreamURL),
|
||||
}
|
||||
}
|
||||
|
||||
// GetMemorySpec returns the ServiceAdapter spec for the memory service.
|
||||
func GetMemorySpec(upstreamURL string) *Spec {
|
||||
return &Spec{
|
||||
ServiceName: "memory",
|
||||
Upstream: Upstream{
|
||||
URL: upstreamURL,
|
||||
TimeoutSeconds: 30,
|
||||
},
|
||||
Auth: Auth{
|
||||
Required: true,
|
||||
Capability: "memory:read",
|
||||
},
|
||||
Resources: []Resource{
|
||||
{
|
||||
Name: "ingest",
|
||||
Methods: []Method{{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/memory/ingest",
|
||||
Auth: &Auth{Required: true, Capability: "memory:write"},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "query",
|
||||
Methods: []Method{{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/memory/query",
|
||||
Auth: &Auth{Required: true, Capability: "memory:read"},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "skills",
|
||||
Methods: []Method{{
|
||||
Verb: "GET",
|
||||
UpstreamPath: "/memory/skills",
|
||||
}},
|
||||
},
|
||||
{
|
||||
Name: "standing-context",
|
||||
Methods: []Method{{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/memory/standing-context",
|
||||
Auth: &Auth{Required: true, Capability: "memory:read"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -53,20 +53,17 @@ func GetPoimenSpec() *Spec {
|
||||
},
|
||||
},
|
||||
},
|
||||
// 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",
|
||||
UpstreamPath: "/v1/poimen/executions/{id}",
|
||||
Auth: &Auth{Required: true, Capability: "workflow:read"},
|
||||
},
|
||||
{
|
||||
Verb: "DELETE",
|
||||
UpstreamPath: "/v1/poimen/executions/detail",
|
||||
UpstreamPath: "/v1/poimen/executions/{id}",
|
||||
Auth: &Auth{Required: true, Capability: "workflow:execute"},
|
||||
},
|
||||
},
|
||||
@@ -75,7 +72,7 @@ func GetPoimenSpec() *Spec {
|
||||
Name: "execution-history",
|
||||
Methods: []Method{{
|
||||
Verb: "GET",
|
||||
UpstreamPath: "/v1/poimen/executions/history",
|
||||
UpstreamPath: "/v1/poimen/executions/{id}/history",
|
||||
Auth: &Auth{Required: true, Capability: "workflow:read"},
|
||||
}},
|
||||
},
|
||||
@@ -83,7 +80,7 @@ func GetPoimenSpec() *Spec {
|
||||
Name: "execution-stream",
|
||||
Methods: []Method{{
|
||||
Verb: "GET",
|
||||
UpstreamPath: "/v1/poimen/executions/stream",
|
||||
UpstreamPath: "/v1/poimen/executions/{id}/stream",
|
||||
Auth: &Auth{Required: true, Capability: "workflow:read"},
|
||||
}},
|
||||
},
|
||||
@@ -91,7 +88,7 @@ func GetPoimenSpec() *Spec {
|
||||
Name: "execution-signal",
|
||||
Methods: []Method{{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/v1/poimen/executions/signal",
|
||||
UpstreamPath: "/v1/poimen/executions/{id}/signal",
|
||||
Auth: &Auth{Required: true, Capability: "workflow:execute"},
|
||||
}},
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
|
||||
)
|
||||
|
||||
@@ -75,13 +74,8 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -104,7 +98,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 +115,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",
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package serviceadapter
|
||||
|
||||
import (
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/sessions"
|
||||
)
|
||||
|
||||
// NewSessionAdapter creates a ServiceAdapter for the sessions service.
|
||||
//
|
||||
// Route mapping (all via X-Service: sessions):
|
||||
//
|
||||
// X-Resource Verb upstream path handler
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// sessions POST /v1/sessions/create create session
|
||||
// sessions GET /v1/sessions/list list sessions (?status=,?cwd=)
|
||||
// session GET /v1/sessions/get get session (?session_id=)
|
||||
// session PATCH /v1/sessions/patch update session(?session_id=)
|
||||
// milestones POST /v1/sessions/milestones/create create milestone(?session_id=)
|
||||
// episodes GET /v1/sessions/episodes/list list episodes (?session_id=)
|
||||
// episode GET /v1/sessions/episodes/get get episode (?session_id=,?episode_id=)
|
||||
//
|
||||
// Path params (:id, :eid) are passed as query params because the ServiceAdapter
|
||||
// dispatcher does not substitute URL path parameters.
|
||||
func NewSessionAdapter() *ServiceAdapter {
|
||||
return &ServiceAdapter{
|
||||
Namespace: "poimen",
|
||||
ServiceName: "sessions",
|
||||
Handler: sessions.NewHandler(),
|
||||
Spec: *GetSessionSpec(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetSessionSpec returns the ServiceAdapter spec for the sessions service.
|
||||
func GetSessionSpec() *Spec {
|
||||
return &Spec{
|
||||
ServiceName: "sessions",
|
||||
Upstream: Upstream{
|
||||
URL: "internal://sessions",
|
||||
TimeoutSeconds: 30,
|
||||
},
|
||||
Auth: Auth{
|
||||
Required: true,
|
||||
Capability: "sessions:read",
|
||||
},
|
||||
Resources: []Resource{
|
||||
{
|
||||
Name: "sessions",
|
||||
Methods: []Method{
|
||||
{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/v1/sessions/create",
|
||||
Auth: &Auth{Required: true, Capability: "sessions:write"},
|
||||
},
|
||||
{
|
||||
Verb: "GET",
|
||||
UpstreamPath: "/v1/sessions/list",
|
||||
Auth: &Auth{Required: true, Capability: "sessions:read"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "session",
|
||||
Methods: []Method{
|
||||
{
|
||||
Verb: "GET",
|
||||
UpstreamPath: "/v1/sessions/get",
|
||||
Auth: &Auth{Required: true, Capability: "sessions:read"},
|
||||
},
|
||||
{
|
||||
Verb: "PATCH",
|
||||
UpstreamPath: "/v1/sessions/patch",
|
||||
Auth: &Auth{Required: true, Capability: "sessions:write"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "milestones",
|
||||
Methods: []Method{
|
||||
{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/v1/sessions/milestones/create",
|
||||
Auth: &Auth{Required: true, Capability: "sessions:write"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "episodes",
|
||||
Methods: []Method{
|
||||
{
|
||||
Verb: "GET",
|
||||
UpstreamPath: "/v1/sessions/episodes/list",
|
||||
Auth: &Auth{Required: true, Capability: "sessions:read"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "episode",
|
||||
Methods: []Method{
|
||||
{
|
||||
Verb: "GET",
|
||||
UpstreamPath: "/v1/sessions/episodes/get",
|
||||
Auth: &Auth{Required: true, Capability: "sessions:read"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,514 +0,0 @@
|
||||
// Package sessions provides the internal handler for the sessions service adapter.
|
||||
// It bridges the api-gw REST surface to poimen-graphql for all reads and writes:
|
||||
//
|
||||
// - Reads: hybridSearch / entity queries
|
||||
// - Writes: upsertEntity mutation (direct, no LLM extraction)
|
||||
//
|
||||
// All IDs passed by the caller (session_id, episode_id) go as query params because
|
||||
// the ServiceAdapter dispatcher does not substitute URL path parameters.
|
||||
// episode_id is sha256(session_id + milestone_id + attempt) — callers compute it the
|
||||
// same way; the handler accepts it on write and returns it on read.
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGraphQLURL = "http://poimen-graphql.poimen.svc.cluster.local:8090/graphql"
|
||||
)
|
||||
|
||||
// Handler handles session REST endpoints via internal service calls.
|
||||
// All traffic (reads and writes) goes to graphqlURL (poimen-graphql).
|
||||
type Handler struct {
|
||||
graphqlURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewHandler creates a Handler reading upstream URL from env var.
|
||||
// GRAPHQL_UPSTREAM_URL — default: defaultGraphQLURL
|
||||
func NewHandler() *Handler {
|
||||
gql := os.Getenv("GRAPHQL_UPSTREAM_URL")
|
||||
if gql == "" {
|
||||
gql = defaultGraphQLURL
|
||||
}
|
||||
return &Handler{
|
||||
graphqlURL: gql,
|
||||
client: &http.Client{Timeout: 30 * time.Second, Transport: tracing.NewTransport(nil)},
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP routes by X-Upstream-Path set by the dispatcher.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.Header.Get("X-Upstream-Path")
|
||||
switch path {
|
||||
case "/v1/sessions/create":
|
||||
h.handleCreate(w, r)
|
||||
case "/v1/sessions/list":
|
||||
h.handleList(w, r)
|
||||
case "/v1/sessions/get":
|
||||
h.handleGet(w, r)
|
||||
case "/v1/sessions/patch":
|
||||
h.handlePatch(w, r)
|
||||
case "/v1/sessions/milestones/create":
|
||||
h.handleCreateMilestone(w, r)
|
||||
case "/v1/sessions/episodes/list":
|
||||
h.handleListEpisodes(w, r)
|
||||
case "/v1/sessions/episodes/get":
|
||||
h.handleGetEpisode(w, r)
|
||||
default:
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "sessions endpoint not found"})
|
||||
}
|
||||
}
|
||||
|
||||
// ── request / response types ──────────────────────────────────────────────────
|
||||
|
||||
type createSessionReq struct {
|
||||
Name string `json:"name"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
}
|
||||
|
||||
type sessionResp struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Name string `json:"name"`
|
||||
Goal string `json:"goal,omitempty"`
|
||||
Status string `json:"status"`
|
||||
MemoryProject string `json:"memory_project"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type patchSessionReq struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Goal *string `json:"goal,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type createMilestoneReq struct {
|
||||
Title string `json:"title"`
|
||||
OrderIdx int `json:"order_idx"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
type milestoneResp struct {
|
||||
MilestoneID string `json:"milestone_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
OrderIdx int `json:"order_idx"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ── handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /v1/sessions (X-Resource: sessions)
|
||||
func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var req createSessionReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
project := "session-" + sessionID
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
summaryBytes, err := json.Marshal(map[string]any{
|
||||
"id": sessionID,
|
||||
"name": req.Name,
|
||||
"goal": req.Goal,
|
||||
"status": "active",
|
||||
"cwd": req.Cwd,
|
||||
"memory_project": project,
|
||||
"created_at": now,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := h.upsertEntity(r, project, sessionID, "SESSION", string(summaryBytes)); err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, sessionResp{
|
||||
SessionID: sessionID,
|
||||
Name: req.Name,
|
||||
Goal: req.Goal,
|
||||
Status: "active",
|
||||
MemoryProject: project,
|
||||
Cwd: req.Cwd,
|
||||
CreatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /v1/sessions (X-Resource: sessions) ?status=active&cwd=/path
|
||||
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
status := q.Get("status")
|
||||
cwd := q.Get("cwd")
|
||||
limitStr := q.Get("limit")
|
||||
limit := 50
|
||||
if limitStr != "" {
|
||||
fmt.Sscanf(limitStr, "%d", &limit) //nolint:errcheck
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
searchQuery := "session"
|
||||
if status != "" {
|
||||
searchQuery = fmt.Sprintf("session status:%s", status)
|
||||
}
|
||||
if cwd != "" {
|
||||
searchQuery = fmt.Sprintf("%s cwd:%s", searchQuery, cwd)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// EpisodeID computes the deterministic episode ID per §4.14 Q6:
|
||||
// sha256(session_id + milestone_id + attempt).
|
||||
// Exported so callers can verify the value the server would produce.
|
||||
func EpisodeID(sessionID, milestoneID string, attempt int) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(fmt.Sprintf("%s%s%d", sessionID, milestoneID, attempt)))
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data) //nolint:errcheck
|
||||
}
|
||||
@@ -1,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)
|
||||
}
|
||||
}
|
||||
+45
-23
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -32,29 +33,26 @@ func (p *PoimenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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:
|
||||
case strings.HasPrefix(path, "/v1/poimen/executions/") && strings.HasSuffix(path, "/stream") && r.Method == http.MethodGet:
|
||||
p.handleStream(w, r)
|
||||
case path == "/v1/poimen/executions/signal" && r.Method == http.MethodPost:
|
||||
case strings.HasPrefix(path, "/v1/poimen/executions/") && strings.HasSuffix(path, "/signal") && r.Method == http.MethodPost:
|
||||
p.handleSignal(w, r)
|
||||
case path == "/v1/poimen/executions/history" && r.Method == http.MethodGet:
|
||||
case strings.HasPrefix(path, "/v1/poimen/executions/") && strings.HasSuffix(path, "/history") && r.Method == http.MethodGet:
|
||||
p.handleHistory(w, r)
|
||||
case path == "/v1/poimen/executions/detail" && r.Method == http.MethodDelete:
|
||||
case strings.HasPrefix(path, "/v1/poimen/executions/") && r.Method == http.MethodDelete:
|
||||
p.handleTerminate(w, r)
|
||||
case path == "/v1/poimen/executions/detail" && r.Method == http.MethodGet:
|
||||
case strings.HasPrefix(path, "/v1/poimen/executions/") && 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
|
||||
// extractWorkflowID extracts the workflow ID from paths like /v1/poimen/executions/{id}/...
|
||||
func extractWorkflowID(path string) string {
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/v1/poimen/executions/"), "/")
|
||||
if len(parts) > 0 && parts[0] != "" {
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -178,9 +176,13 @@ func (p *PoimenHandler) handleDescribe(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
workflowID := getWorkflowID(r)
|
||||
path := r.Header.Get("X-Upstream-Path")
|
||||
if path == "" {
|
||||
path = r.URL.Path
|
||||
}
|
||||
workflowID := extractWorkflowID(path)
|
||||
if workflowID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id required in path"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -207,9 +209,13 @@ func (p *PoimenHandler) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
workflowID := getWorkflowID(r)
|
||||
path := r.Header.Get("X-Upstream-Path")
|
||||
if path == "" {
|
||||
path = r.URL.Path
|
||||
}
|
||||
workflowID := extractWorkflowID(path)
|
||||
if workflowID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id required in path"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -242,9 +248,15 @@ func (p *PoimenHandler) handleSignal(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
workflowID := getWorkflowID(r)
|
||||
path := r.Header.Get("X-Upstream-Path")
|
||||
if path == "" {
|
||||
path = r.URL.Path
|
||||
}
|
||||
// Remove /signal suffix to get workflow ID
|
||||
cleanPath := strings.TrimSuffix(path, "/signal")
|
||||
workflowID := extractWorkflowID(cleanPath)
|
||||
if workflowID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id required in path"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -278,9 +290,13 @@ func (p *PoimenHandler) handleTerminate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
workflowID := getWorkflowID(r)
|
||||
path := r.Header.Get("X-Upstream-Path")
|
||||
if path == "" {
|
||||
path = r.URL.Path
|
||||
}
|
||||
workflowID := extractWorkflowID(path)
|
||||
if workflowID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id required in path"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -301,9 +317,15 @@ func (p *PoimenHandler) handleTerminate(w http.ResponseWriter, r *http.Request)
|
||||
// --- Stream (delegate to existing SSE handler) ---
|
||||
|
||||
func (p *PoimenHandler) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
workflowID := getWorkflowID(r)
|
||||
path := r.Header.Get("X-Upstream-Path")
|
||||
if path == "" {
|
||||
path = r.URL.Path
|
||||
}
|
||||
// Remove /stream suffix
|
||||
cleanPath := strings.TrimSuffix(path, "/stream")
|
||||
workflowID := extractWorkflowID(cleanPath)
|
||||
if workflowID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id required in path"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/notification"
|
||||
)
|
||||
@@ -21,7 +20,6 @@ import (
|
||||
type ForgejoHandler struct {
|
||||
secret string
|
||||
gotify *notification.GotifyClient
|
||||
promoter *ImagePromoter
|
||||
}
|
||||
|
||||
// NewForgejoHandler creates a handler from environment variables.
|
||||
@@ -39,7 +37,6 @@ func NewForgejoHandler() *ForgejoHandler {
|
||||
return &ForgejoHandler{
|
||||
secret: os.Getenv("FORGEJO_WEBHOOK_SECRET"),
|
||||
gotify: client,
|
||||
promoter: NewImagePromoter(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,23 +65,17 @@ func (h *ForgejoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
event := r.Header.Get("X-Gitea-Event")
|
||||
if event == "" {
|
||||
event = r.Header.Get("X-GitHub-Event")
|
||||
}
|
||||
|
||||
title, message, priority := h.formatMessage(event, body)
|
||||
if title == "" {
|
||||
// Unhandled event type — ack and ignore
|
||||
@@ -108,81 +99,6 @@ func (h *ForgejoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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=")
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
// Package webhook — ImagePromoter patches ArgoCD Application CRs when a
|
||||
// Forgejo container package webhook fires. It replaces ArgoCD Image Updater
|
||||
// with a webhook-driven flow:
|
||||
//
|
||||
// 1. Forgejo fires X-Gitea-Event: package (action: created, type: container)
|
||||
// 2. ForgejoHandler calls ImagePromoter.Promote(image, tag)
|
||||
// 3. Promoter fetches the digest from Forgejo registry v2 API
|
||||
// 4. Promoter patches the ArgoCD Application CR via the k8s API server
|
||||
// using the in-cluster ServiceAccount token.
|
||||
//
|
||||
// No external dependencies — raw HTTP only.
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
k8sAPIHost = "https://kubernetes.default.svc"
|
||||
argoNamespace = "argocd"
|
||||
saTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
saCACertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||
defaultRegBase = "http://forgejo-gitea-http.cicd.svc.cluster.local:3000"
|
||||
defaultRegUser = "rock"
|
||||
promoterTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
// promotionEntry maps a fully-qualified container image to an ArgoCD Application.
|
||||
type promotionEntry struct {
|
||||
Image string // e.g. "forgejo.riotpiao.com/rock/api-gateway"
|
||||
Application string // ArgoCD Application name in namespace argocd
|
||||
SourceIndex int // index into spec.sources[]; -1 = spec.source (singular, helm)
|
||||
Type string // "kustomize" or "helm"
|
||||
}
|
||||
|
||||
// promotionTable is the static image-to-application mapping.
|
||||
// Order matters for poimen-toolkit: two images share the same source index.
|
||||
var promotionTable = []promotionEntry{
|
||||
{"forgejo.riotpiao.com/rock/api-gateway", "api-gw", 0, "kustomize"},
|
||||
{"forgejo.riotpiao.com/rock/portfolio", "portfolio", 0, "kustomize"},
|
||||
{"forgejo.riotpiao.com/riotpiao-poimen/poimen-workflows", "poimen", 0, "kustomize"},
|
||||
{"forgejo.riotpiao.com/riotpiao-poimen/poimen-memory", "poimen", 1, "kustomize"},
|
||||
// sourceIndex 2 in poimen is a non-container source (chart/config repo) — not promoted.
|
||||
{"forgejo.riotpiao.com/riotpiao-poimen/poimen-frontend", "poimen", 3, "kustomize"},
|
||||
{"forgejo.riotpiao.com/riotpiao-poimen/poimen-toolkit-controller", "poimen-toolkit", 0, "kustomize"},
|
||||
{"forgejo.riotpiao.com/riotpiao-poimen/pi-worker", "poimen-toolkit", 0, "kustomize"},
|
||||
{"forgejo.riotpiao.com/rock/forgejo-runner-golang", "forgejo-runner-golang", -1, "helm"},
|
||||
{"forgejo.riotpiao.com/rock/forgejo-runner-node", "forgejo-runner-node", -1, "helm"},
|
||||
{"forgejo.riotpiao.com/rock/forgejo-runner-rust", "forgejo-runner-rust", -1, "helm"},
|
||||
}
|
||||
|
||||
// ImagePromoter fetches digests and patches ArgoCD Applications.
|
||||
type ImagePromoter struct {
|
||||
registryBase string
|
||||
registryUser string
|
||||
registryPAT string
|
||||
k8sHost string // k8s API server base URL; defaults to k8sAPIHost
|
||||
k8sClient *http.Client // mTLS with SA CA cert
|
||||
regClient *http.Client // plain HTTP to internal Forgejo
|
||||
}
|
||||
|
||||
// NewImagePromoter builds an ImagePromoter from environment and in-cluster SA files.
|
||||
// Returns nil (not an error) when REGISTRY_PAT is empty — promoter is disabled.
|
||||
func NewImagePromoter() *ImagePromoter {
|
||||
pat := os.Getenv("REGISTRY_PAT")
|
||||
if pat == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
regBase := os.Getenv("REGISTRY_BASE")
|
||||
if regBase == "" {
|
||||
regBase = defaultRegBase
|
||||
}
|
||||
regUser := os.Getenv("REGISTRY_USER")
|
||||
if regUser == "" {
|
||||
regUser = defaultRegUser
|
||||
}
|
||||
|
||||
caCert, _ := os.ReadFile(saCACertPath)
|
||||
return &ImagePromoter{
|
||||
registryBase: regBase,
|
||||
registryUser: regUser,
|
||||
registryPAT: pat,
|
||||
k8sClient: buildK8sClient(caCert),
|
||||
regClient: &http.Client{Timeout: promoterTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// buildK8sClient creates an HTTP client that trusts the in-cluster CA cert.
|
||||
func buildK8sClient(caCert []byte) *http.Client {
|
||||
transport := &http.Transport{}
|
||||
if len(caCert) > 0 {
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(caCert)
|
||||
transport.TLSClientConfig = &tls.Config{RootCAs: pool}
|
||||
} else {
|
||||
log.Printf("image-promoter: WARNING SA CA cert not found at %s — using InsecureSkipVerify (dev mode only)", saCACertPath)
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
|
||||
}
|
||||
return &http.Client{Timeout: promoterTimeout, Transport: transport}
|
||||
}
|
||||
|
||||
// saToken reads the current in-cluster ServiceAccount token.
|
||||
// Re-reads on every call to handle token rotation.
|
||||
func saToken() string {
|
||||
b, err := os.ReadFile(saTokenPath)
|
||||
if err != nil {
|
||||
log.Printf("image-promoter: WARNING failed to read SA token %s: %v", saTokenPath, err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// Promote fetches the digest for image:tag and patches the corresponding
|
||||
// ArgoCD Application CR. Returns an error if the image is unknown.
|
||||
// Retries up to 3 times on k8s 409 Conflict (optimistic concurrency for apps
|
||||
// like poimen-toolkit that share a source index across multiple images).
|
||||
func (p *ImagePromoter) Promote(ctx context.Context, image, tag string) error {
|
||||
entry := lookupEntry(image)
|
||||
if entry == nil {
|
||||
return fmt.Errorf("image %q not in promotion table — skipping", image)
|
||||
}
|
||||
|
||||
// Parse owner/name from "forgejo.riotpiao.com/owner/name"
|
||||
withoutHost := strings.TrimPrefix(image, "forgejo.riotpiao.com/")
|
||||
parts := strings.SplitN(withoutHost, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("cannot parse owner/name from image %q", image)
|
||||
}
|
||||
owner, name := parts[0], parts[1]
|
||||
|
||||
digest, err := p.fetchDigest(ctx, owner, name, tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch digest for %s:%s: %w", image, tag, err)
|
||||
}
|
||||
|
||||
log.Printf("image-promoter: digest %s → %s application=%s sourceIndex=%d",
|
||||
image, digest, entry.Application, entry.SourceIndex)
|
||||
|
||||
// Retry on 409 Conflict: concurrent promotions for the same (app, sourceIndex)
|
||||
// (e.g. poimen-toolkit-controller and pi-worker) can race on the same source slot.
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
err = p.patchApplication(ctx, *entry, image, digest)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !strings.Contains(err.Error(), "409") || attempt == 3 {
|
||||
return err
|
||||
}
|
||||
log.Printf("image-promoter: 409 conflict on PATCH %s (attempt %d/3), retrying",
|
||||
entry.Application, attempt)
|
||||
select {
|
||||
case <-time.After(time.Duration(attempt) * 150 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// lookupEntry returns the promotion entry for the given image, or nil if not found.
|
||||
func lookupEntry(image string) *promotionEntry {
|
||||
for i := range promotionTable {
|
||||
if promotionTable[i].Image == image {
|
||||
return &promotionTable[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchDigest queries the Forgejo OCI registry v2 API for the manifest digest.
|
||||
// Uses HTTP Basic auth with the configured PAT.
|
||||
func (p *ImagePromoter) fetchDigest(ctx context.Context, owner, name, tag string) (string, error) {
|
||||
url := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", p.registryBase, owner, name, tag)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(p.registryUser, p.registryPAT)
|
||||
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
req.Header.Add("Accept", "application/vnd.oci.image.manifest.v1+json")
|
||||
|
||||
resp, err := p.regClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("registry returned %d for %s/%s:%s", resp.StatusCode, owner, name, tag)
|
||||
}
|
||||
|
||||
digest := resp.Header.Get("Docker-Content-Digest")
|
||||
if digest == "" {
|
||||
return "", fmt.Errorf("Docker-Content-Digest header missing from registry response")
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// patchApplication performs a read-modify-write on the ArgoCD Application CR.
|
||||
// It reads the current spec to preserve existing image entries, updates the
|
||||
// relevant entry, then sends a JSON Patch to the k8s API server.
|
||||
func (p *ImagePromoter) patchApplication(ctx context.Context, entry promotionEntry, image, digest string) error {
|
||||
app, err := p.getApplication(ctx, entry.Application)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get application %s: %w", entry.Application, err)
|
||||
}
|
||||
|
||||
var patch []map[string]any
|
||||
|
||||
switch entry.Type {
|
||||
case "kustomize":
|
||||
images := getKustomizeImages(app, entry.SourceIndex)
|
||||
newImages := updateImageEntry(images, image, digest)
|
||||
// Patch the full kustomize block (not just /kustomize/images) so the
|
||||
// op succeeds even when the kustomize key is absent from the source.
|
||||
// These apps only use kustomize overrides for image pinning, so
|
||||
// replacing the whole block is safe.
|
||||
patch = []map[string]any{{
|
||||
"op": "add",
|
||||
"path": fmt.Sprintf("/spec/sources/%d/kustomize", entry.SourceIndex),
|
||||
"value": map[string]any{"images": newImages},
|
||||
}}
|
||||
|
||||
case "helm":
|
||||
// Helm runners use spec.source (singular). Patch helm parameters
|
||||
// to set runner.image.repository and runner.image.tag with digest.
|
||||
existing := getHelmParameters(app)
|
||||
updates := []map[string]string{
|
||||
{"name": "runner.image.repository", "value": image},
|
||||
{"name": "runner.image.tag", "value": "latest@" + digest},
|
||||
}
|
||||
merged := mergeHelmParams(existing, updates)
|
||||
patch = []map[string]any{
|
||||
// "add" works whether parameters key exists or not (replace fails on absent key)
|
||||
{"op": "add", "path": "/spec/source/helm/parameters", "value": merged},
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown promotion type %q for %s", entry.Type, entry.Application)
|
||||
}
|
||||
|
||||
return p.applyJSONPatch(ctx, entry.Application, patch)
|
||||
}
|
||||
|
||||
// k8sBase returns the configured k8s host or the default in-cluster address.
|
||||
func (p *ImagePromoter) k8sBase() string {
|
||||
if p.k8sHost != "" {
|
||||
return p.k8sHost
|
||||
}
|
||||
return k8sAPIHost
|
||||
}
|
||||
|
||||
// getApplication fetches the current ArgoCD Application CR from the k8s API.
|
||||
func (p *ImagePromoter) getApplication(ctx context.Context, name string) (map[string]any, error) {
|
||||
url := fmt.Sprintf("%s/apis/argoproj.io/v1alpha1/namespaces/%s/applications/%s",
|
||||
p.k8sBase(), argoNamespace, name)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+saToken())
|
||||
|
||||
resp, err := p.k8sClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("k8s GET returned %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var app map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&app); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// applyJSONPatch sends RFC 6902 JSON Patch to the ArgoCD Application.
|
||||
func (p *ImagePromoter) applyJSONPatch(ctx context.Context, appName string, patch []map[string]any) error {
|
||||
patchBytes, err := json.Marshal(patch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal patch: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/apis/argoproj.io/v1alpha1/namespaces/%s/applications/%s",
|
||||
p.k8sBase(), argoNamespace, appName)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(patchBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
||||
req.Header.Set("Authorization", "Bearer "+saToken())
|
||||
|
||||
resp, err := p.k8sClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("k8s PATCH returned %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── spec helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// getKustomizeImages extracts spec.sources[N].kustomize.images.
|
||||
// Returns nil if the path does not exist.
|
||||
func getKustomizeImages(app map[string]any, sourceIndex int) []string {
|
||||
spec, _ := app["spec"].(map[string]any)
|
||||
sources, _ := spec["sources"].([]any)
|
||||
if sourceIndex < 0 || sourceIndex >= len(sources) {
|
||||
return nil
|
||||
}
|
||||
src, _ := sources[sourceIndex].(map[string]any)
|
||||
kust, _ := src["kustomize"].(map[string]any)
|
||||
return toStringSlice(kust["images"])
|
||||
}
|
||||
|
||||
// getHelmParameters extracts spec.source.helm.parameters.
|
||||
func getHelmParameters(app map[string]any) []map[string]string {
|
||||
spec, _ := app["spec"].(map[string]any)
|
||||
source, _ := spec["source"].(map[string]any)
|
||||
helm, _ := source["helm"].(map[string]any)
|
||||
raw, _ := helm["parameters"].([]any)
|
||||
|
||||
params := make([]map[string]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
p := map[string]string{}
|
||||
if n, ok := m["name"].(string); ok {
|
||||
p["name"] = n
|
||||
}
|
||||
if v, ok := m["value"].(string); ok {
|
||||
p["value"] = v
|
||||
}
|
||||
params = append(params, p)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// updateImageEntry replaces the entry for image in the images slice, or appends
|
||||
// if not present. The new entry format is "{image}@{digest}".
|
||||
// Matching is by image name (prefix before ':' or '@').
|
||||
func updateImageEntry(images []string, image, digest string) []string {
|
||||
newEntry := image + "@" + digest
|
||||
for i, img := range images {
|
||||
// Strip tag/digest suffix to get the bare name
|
||||
bare := img
|
||||
if idx := strings.IndexAny(img, ":@"); idx >= 0 {
|
||||
bare = img[:idx]
|
||||
}
|
||||
if bare == image {
|
||||
result := make([]string, len(images))
|
||||
copy(result, images)
|
||||
result[i] = newEntry
|
||||
return result
|
||||
}
|
||||
}
|
||||
return append(images, newEntry)
|
||||
}
|
||||
|
||||
// mergeHelmParams merges update params into existing, preserving unrelated params.
|
||||
func mergeHelmParams(existing, updates []map[string]string) []map[string]string {
|
||||
idx := make(map[string]string, len(updates))
|
||||
for _, u := range updates {
|
||||
idx[u["name"]] = u["value"]
|
||||
}
|
||||
|
||||
result := make([]map[string]string, 0, len(existing)+len(updates))
|
||||
replaced := make(map[string]bool)
|
||||
for _, p := range existing {
|
||||
if val, ok := idx[p["name"]]; ok {
|
||||
result = append(result, map[string]string{"name": p["name"], "value": val})
|
||||
replaced[p["name"]] = true
|
||||
} else {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
for _, u := range updates {
|
||||
if !replaced[u["name"]] {
|
||||
result = append(result, u)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// toStringSlice converts []any to []string, skipping non-strings.
|
||||
func toStringSlice(v any) []string {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,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)
|
||||
}
|
||||
@@ -95,14 +95,6 @@ spec:
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-34
@@ -122,7 +122,7 @@ spec:
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 7233
|
||||
# Allow to Poimen (memory:8080 + graphql:8090 + redis:6379)
|
||||
# Allow to Poimen (memory/semantic search)
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
@@ -130,18 +130,6 @@ spec:
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
- protocol: TCP
|
||||
port: 8090
|
||||
- protocol: TCP
|
||||
port: 6379
|
||||
# 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.
|
||||
@@ -154,24 +142,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
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# RBAC for api-gateway image promotion.
|
||||
# Grants the api-gateway ServiceAccount get+patch on ArgoCD Applications
|
||||
# so it can update kustomize.images / helm.parameters after a container push.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: image-promoter
|
||||
labels:
|
||||
app: api-gateway
|
||||
rules:
|
||||
- apiGroups: ["argoproj.io"]
|
||||
resources: ["applications"]
|
||||
verbs: ["get", "patch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: image-promoter-api-gateway
|
||||
labels:
|
||||
app: api-gateway
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: api-gateway
|
||||
namespace: api
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: image-promoter
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
Reference in New Issue
Block a user