Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ linters:
alias: fwkgaie
- pkg: github.com/llm-d/llm-d-router/test/framework/epp
alias: fwkepp
- pkg: github.com/llm-d/llm-d-router/test/framework/epp/harness
alias: eppharness
revive: # see https://github.com/mgechev/revive#available-rules for all options
rules:
- name: blank-imports
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ sidecar_TEST_PACKAGES = ./pkg/sidecar/...
# block (epp+sidecar only); CI's baseline cache unions all coverage/*.out, so
# framework enters the CI baseline once this is on main. The PR run reports
# coverage/framework.out as a new component.
framework_TEST_PACKAGES = ./test/framework/...
# The EPP harness is excluded: it is envtest-driven and exercised by
# make test-integration-hermetic, so unit coverage would report 0% for code
# that is in fact covered.
framework_TEST_PACKAGES = $$(go list ./test/framework/... | grep -vE '/test/framework/epp/harness($$|/)' | tr '\n' ' ')

# Internal variables for generic targets
epp_IMAGE = $(EPP_IMAGE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

package epp
// Package harness boots a shared envtest environment and per-test EPP servers for
// integration suites.
//
// It sits at the top of the framework layering and may import anything, including
// pkg/epp/server and cmd/epp/runner; only integration and e2e suites import it.
package harness

import (
"context"
Expand All @@ -24,6 +29,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -585,3 +591,60 @@ func (h *TestHarness) ExpectMetrics(expected map[string]string) {
}
}
}

// --- Data Structures & Metrics Helpers ---

type PodState struct {
index int
queueSize int
kvCacheUsage float64
activeModels []string
}

// P constructs a Pod State: Index, Queue, KV%, Models...
// Usage: P(0, 5, 0.2, "model-a")
func P(idx int, q int, kv float64, models ...string) PodState {
return PodState{index: idx, queueSize: q, kvCacheUsage: kv, activeModels: models}
}

type label struct{ name, value string }

func labelsToString(labels []label) string {
parts := make([]string, len(labels))
for i, l := range labels {
parts[i] = fmt.Sprintf("%s=%q", l.name, l.value)
}
return strings.Join(parts, ",")
}

// MetricReqTotal renders the expected inference_objective_request_total exposition text.
func MetricReqTotal(model, target string, priority int) string {
return fmt.Sprintf(`
# HELP inference_objective_request_total [ALPHA] [Deprecated: Use llm_d_epp_request_total] Counter of inference objective requests broken out for each model and target model.
# TYPE inference_objective_request_total counter
inference_objective_request_total{%s} 1
`, labelsToString([]label{{"model_name", model}, {"priority", strconv.Itoa(priority)}, {"target_model_name", target}}))
}

// MetricReadyPods renders the expected inference_pool_ready_pods exposition text.
func MetricReadyPods(count int) string {
return fmt.Sprintf(`
# HELP inference_pool_ready_pods [ALPHA] [Deprecated: Use llm_d_epp_ready_endpoints] The number of ready pods in the inference server pool.
# TYPE inference_pool_ready_pods gauge
inference_pool_ready_pods{%s} %d
`, labelsToString([]label{{"name", TestPoolName}}), count)
}

// CleanMetric removes indentation from multiline metric strings and ensures a trailing newline exists, which is
// required by the Prometheus text parser.
func CleanMetric(s string) string {
lines := strings.Split(s, "\n")
var cleaned []string
for _, l := range lines {
trimmed := strings.TrimSpace(l)
if trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
return strings.Join(cleaned, "\n") + "\n"
}
91 changes: 16 additions & 75 deletions test/integration/epp/common_tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,14 @@ package epp

import (
"encoding/json"
"fmt"
"strconv"
"strings"

envoyCorev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3"

reqcommon "github.com/llm-d/llm-d-router/pkg/common/request"
fwkepp "github.com/llm-d/llm-d-router/test/framework/epp"
eppharness "github.com/llm-d/llm-d-router/test/framework/epp/harness"
)

// Model name constants shared across test suites.
Expand All @@ -52,7 +50,7 @@ func buildEnvoyHeaders(headers map[string]string) []*envoyCorev3.HeaderValue {
// This simulates the "Subset Load Balancing" flow where EPP picks a specific pod IP.
func ReqSubset(prompt, model, target string, subsets ...string) []*extProcPb.ProcessingRequest {
// Uses the shared low-level generator which handles the metadata construction
return fwkepp.GenerateStreamedRequestSet(Logger(), prompt, model, target, subsets)
return fwkepp.GenerateStreamedRequestSet(eppharness.Logger(), prompt, model, target, subsets)
}

// ReqResponseOnly creates a sequence simulating only the response phase from Envoy.
Expand Down Expand Up @@ -276,7 +274,7 @@ func ExpectGRPCStreamResp(chunks ...string) []*extProcPb.ProcessingResponse {
type testCase struct {
name string
requests []*extProcPb.ProcessingRequest
pods []PodState
pods []eppharness.PodState
configText string
wantResponses []*extProcPb.ProcessingResponse
wantMetrics map[string]string
Expand All @@ -294,30 +292,30 @@ func commonTestCases(prio func(int) int) []testCase {
return []testCase{
{
name: "select lower queue and kv cache",
requests: fwkepp.ReqLLM(Logger(), "test1", modelMyModel, modelMyModelTarget),
pods: []PodState{
P(0, 3, 0.2),
P(1, 0, 0.1), // Winner (Low Queue, Low KV)
P(2, 10, 0.2),
requests: fwkepp.ReqLLM(eppharness.Logger(), "test1", modelMyModel, modelMyModelTarget),
pods: []eppharness.PodState{
eppharness.P(0, 3, 0.2),
eppharness.P(1, 0, 0.1), // Winner (Low Queue, Low KV)
eppharness.P(2, 10, 0.2),
},
wantResponses: ExpectRouteTo("192.168.1.2:8000", modelMyModelTarget, "test1"),
wantMetrics: map[string]string{
"inference_objective_request_total": CleanMetric(MetricReqTotal(modelMyModel, modelMyModelTarget, prio(2))),
"inference_pool_ready_pods": CleanMetric(MetricReadyPods(3)),
"inference_objective_request_total": eppharness.CleanMetric(eppharness.MetricReqTotal(modelMyModel, modelMyModelTarget, prio(2))),
"inference_pool_ready_pods": eppharness.CleanMetric(eppharness.MetricReadyPods(3)),
},
wantSpans: []string{"gateway.request", "gateway.request_orchestration"},
},
{
name: "select active lora, low queue",
requests: fwkepp.ReqLLM(Logger(), "test2", modelSQLLora, modelSQLLoraTarget),
pods: []PodState{
P(0, 0, 0.2, "foo", "bar"),
P(1, 0, 0.1, "foo", modelSQLLoraTarget), // Winner (Has LoRA)
P(2, 10, 0.2, "foo", "bar"),
requests: fwkepp.ReqLLM(eppharness.Logger(), "test2", modelSQLLora, modelSQLLoraTarget),
pods: []eppharness.PodState{
eppharness.P(0, 0, 0.2, "foo", "bar"),
eppharness.P(1, 0, 0.1, "foo", modelSQLLoraTarget), // Winner (Has LoRA)
eppharness.P(2, 10, 0.2, "foo", "bar"),
},
wantResponses: ExpectRouteTo("192.168.1.2:8000", modelSQLLoraTarget, "test2"),
wantMetrics: map[string]string{
"inference_objective_request_total": CleanMetric(MetricReqTotal(modelSQLLora, modelSQLLoraTarget, prio(2))),
"inference_objective_request_total": eppharness.CleanMetric(eppharness.MetricReqTotal(modelSQLLora, modelSQLLoraTarget, prio(2))),
},
},
{
Expand All @@ -338,60 +336,3 @@ func commonTestCases(prio func(int) int) []testCase {
},
}
}

// --- Data Structures & Metrics Helpers ---

type PodState struct {
index int
queueSize int
kvCacheUsage float64
activeModels []string
}

// P constructs a Pod State: Index, Queue, KV%, Models...
// Usage: P(0, 5, 0.2, "model-a")
func P(idx int, q int, kv float64, models ...string) PodState {
return PodState{index: idx, queueSize: q, kvCacheUsage: kv, activeModels: models}
}

type label struct{ name, value string }

func labelsToString(labels []label) string {
parts := make([]string, len(labels))
for i, l := range labels {
parts[i] = fmt.Sprintf("%s=%q", l.name, l.value)
}
return strings.Join(parts, ",")
}

// MetricReqTotal renders the expected inference_objective_request_total exposition text.
func MetricReqTotal(model, target string, priority int) string {
return fmt.Sprintf(`
# HELP inference_objective_request_total [ALPHA] [Deprecated: Use llm_d_epp_request_total] Counter of inference objective requests broken out for each model and target model.
# TYPE inference_objective_request_total counter
inference_objective_request_total{%s} 1
`, labelsToString([]label{{"model_name", model}, {"priority", strconv.Itoa(priority)}, {"target_model_name", target}}))
}

// MetricReadyPods renders the expected inference_pool_ready_pods exposition text.
func MetricReadyPods(count int) string {
return fmt.Sprintf(`
# HELP inference_pool_ready_pods [ALPHA] [Deprecated: Use llm_d_epp_ready_endpoints] The number of ready pods in the inference server pool.
# TYPE inference_pool_ready_pods gauge
inference_pool_ready_pods{%s} %d
`, labelsToString([]label{{"name", TestPoolName}}), count)
}

// CleanMetric removes indentation from multiline metric strings and ensures a trailing newline exists, which is
// required by the Prometheus text parser.
func CleanMetric(s string) string {
lines := strings.Split(s, "\n")
var cleaned []string
for _, l := range lines {
trimmed := strings.TrimSpace(l)
if trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
return strings.Join(cleaned, "\n") + "\n"
}
3 changes: 2 additions & 1 deletion test/integration/epp/datalayer_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"google.golang.org/protobuf/testing/protocmp"

fwkepp "github.com/llm-d/llm-d-router/test/framework/epp"
eppharness "github.com/llm-d/llm-d-router/test/framework/epp/harness"
)

// TestFullDuplexStreamed_DataLayer runs integration tests through the datalayer metrics pipeline.
Expand All @@ -39,7 +40,7 @@ func TestFullDuplexStreamed_DataLayer(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

h := NewTestHarness(ctx, t, WithStandardMode())
h := eppharness.NewTestHarness(ctx, t, eppharness.WithStandardMode())
h.WithBaseResources().WithPods(tc.pods).WaitForSync(len(tc.pods), modelMyModel)
if len(tc.pods) > 0 {
h.WaitForReadyPodsMetric(len(tc.pods))
Expand Down
7 changes: 4 additions & 3 deletions test/integration/epp/dynamic_attributes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
reqcommon "github.com/llm-d/llm-d-router/pkg/common/request"
"github.com/llm-d/llm-d-router/pkg/epp/metadata"
fwkepp "github.com/llm-d/llm-d-router/test/framework/epp"
eppharness "github.com/llm-d/llm-d-router/test/framework/epp/harness"
)

func TestDynamicAttributes_Concurrency(t *testing.T) {
Expand Down Expand Up @@ -73,12 +74,12 @@ flowControl:
`

ctx := t.Context()
h := NewTestHarness(ctx, t, WithConfigText(configText), WithStandardMode())
h := eppharness.NewTestHarness(ctx, t, eppharness.WithConfigText(configText), eppharness.WithStandardMode())
h = h.WithBaseResources()

// Add one pod. We need it to support modelMyModelTarget.
pods := []PodState{
P(0, 0, 0.0, modelMyModelTarget),
pods := []eppharness.PodState{
eppharness.P(0, 0, 0.0, modelMyModelTarget),
}
h.WithPods(pods).WaitForSync(len(pods), modelMyModel)
h.WaitForReadyPodsMetric(len(pods))
Expand Down
10 changes: 7 additions & 3 deletions test/integration/epp/e2e_config_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ You may obtain a copy of the License at

package epp

import "testing"
import (
"testing"

eppharness "github.com/llm-d/llm-d-router/test/framework/epp/harness"
)

// e2eConfigsForSmoke mirrors the YAML strings in test/e2e/configs_test.go so
// the hermetic harness validates that the e2e fixtures actually parse +
Expand Down Expand Up @@ -171,9 +175,9 @@ schedulingProfiles:
func TestE2EConfigs_StartupParseSmoke(t *testing.T) {
for name, yaml := range e2eConfigsForSmoke {
t.Run(name, func(t *testing.T) {
// NewTestHarness fails the test if config-parse / plugin-init
// eppharness.NewTestHarness fails the test if config-parse / plugin-init
// errors — exactly what we want to catch before pushing.
_ = NewTestHarness(t.Context(), t, WithConfigText(yaml))
_ = eppharness.NewTestHarness(t.Context(), t, eppharness.WithConfigText(yaml))
})
}
}
Loading
Loading