Add a local two-cluster end-to-end test (no cloud, no GPU) - #365
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a laptop/CI-friendly local end-to-end integration test for Modelplane that exercises the real reconciliation + routing path on two kind clusters, without requiring cloud credentials or GPUs.
Changes:
- Introduces
nix run .#e2e-localto bootstrap a two-cluster local environment and (optionally) verify the served endpoint returns HTTP 200. - Adds a label-gated GitHub Actions workflow (
test-e2e) that runs the same-- --verifycommand used locally. - Documents the new integration-test layer in
hack/local-e2e/README.mdandCONTRIBUTING.md.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| nix/apps.nix | Adds the e2e-local flake app wrapper that materializes function images and delegates to hack/local-e2e/run.sh. |
| flake.nix | Exposes the new e2e-local app. |
| hack/local-e2e/run.sh | Implements two-cluster orchestration and optional --verify live request check. |
| hack/local-e2e/README.md | Documents design, prerequisites, usage, and caveats for the local e2e flow. |
| hack/local-e2e/dra-example-driver.yaml | Vendors a fake DRA driver to allow DRA claims to bind on GPU-less kind nodes. |
| hack/local-e2e/manifests/00-namespaces.yaml | Creates the ml-team namespace for test resources. |
| hack/local-e2e/manifests/10-inference-gateway.yaml | Defines the control-plane InferenceGateway with MetalLB address pool (subnet-templated by the script). |
| hack/local-e2e/manifests/20-inference-class.yaml | Adds a synthetic InferenceClass advertising a claimable DRA device. |
| hack/local-e2e/manifests/30-inference-cluster.yaml | Registers the workload cluster via source: Existing using a kubeconfig Secret. |
| hack/local-e2e/manifests/40-model-deployment.yaml | Deploys a mock OpenAI-compatible engine to exercise routing without real inference. |
| hack/local-e2e/manifests/50-model-service.yaml | Fronts the deployment with a single OpenAI-compatible endpoint and publishes .status.address. |
| CONTRIBUTING.md | Adds guidance distinguishing unit checks (nix flake check) vs integration e2e (nix run .#e2e-local). |
| .github/workflows/e2e.yml | Adds label-gated CI execution of the local e2e (test-e2e) with diagnostics + teardown. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
e0f151a to
58ab8f6
Compare
58ab8f6 to
c64312f
Compare
c64312f to
75c55b4
Compare
75c55b4 to
827a274
Compare
827a274 to
a118698
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
hack/local-e2e/run.sh:218
curl_statusassumes the caller-provided pod name is unique, butkubectl runerrors are swallowed (|| true). If a previous run crashed and left ane2e-verify-*pod behind, a later run can fail to create the pod and then read stale logs from the old pod, causing false positives/negatives. Generate a unique pod name insidecurl_status(or explicitly delete any existing pod) before running curl.
local pod="$1" url="$2"
shift 2
kubectl --context "$cpctx" -n "$ns" run "$pod" --restart=Never \
--labels=app.kubernetes.io/name=e2e-verify --image="$CURL_IMAGE" \
--command -- curl -sS --max-time 15 -o /dev/null -w '%{http_code}' "$url" "$@" \
hack/local-e2e/dra-example-driver.yaml:71
ClusterRoleBindingis cluster-scoped;metadata.namespaceshould be omitted to avoid confusion and potential schema-validation failures.
kind: ClusterRoleBinding
metadata:
name: dra-example-driver-role-binding
namespace: dra-example-driver
97f1647 to
b49e48d
Compare
b49e48d to
ce57422
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
hack/local-e2e/run.sh:249
- The verify pods use deterministic names (e.g. e2e-verify-oai-1). If a previous run is interrupted before cleanup, re-running can hit an existing pod name; since
kubectl runerrors are ignored, the subsequentkubectl logscall can read stale output and produce a false 200. Make the pod name unique per script invocation (e.g. suffix with$$) to avoid cross-run collisions.
code="$(curl_status "e2e-verify-oai-$attempt" "$addr/v1/chat/completions" -H 'content-type: application/json' -d "$oai")"
hack/local-e2e/run.sh:264
- The Anthropic verify step uses a fixed pod name (e2e-verify-anthropic). If a prior run left this pod behind,
kubectl runcan fail (and is ignored), andkubectl logsmay read stale output from the old pod. Suffix the name with$$(or another unique token) to make runs independent.
mcode="$(curl_status e2e-verify-anthropic "$addr/v1/messages" -H 'content-type: application/json' -H 'anthropic-version: 2023-06-01' -d "$ant")"
| activate: | ||
| - "*.kubernetes.crossplane.io" | ||
| - "*.kubernetes.m.crossplane.io" | ||
| - "*.helm.crossplane.io" |
There was a problem hiding this comment.
we we need the cluster scoped ? - in the apis/mrap.yaml we have
# provider-helm and provider-kubernetes (serving stack, gateway, caches).
- releases.helm.m.crossplane.io
- objects.kubernetes.m.crossplane.io
There was a problem hiding this comment.
Good catch. Dropped the cluster-scoped forms — the BYO compositions run provider-helm/kubernetes v2, whose MRs are namespaced (.m.crossplane.io), exactly what apis/mrap.yaml activates. The lean MRAP now keeps only *.kubernetes.m.crossplane.io and *.helm.m.crossplane.io.
Verified with a fresh run, locally and in CI — both /v1/chat/completions and /v1/messages still return 200. (2063156)
There was a problem hiding this comment.
| exit 0 | ||
| fi | ||
|
|
||
| # --verify: project run returns once the config is healthy and the resources are |
There was a problem hiding this comment.
i wonder if we can run some python test-suites after the project is running in kind - perhaps good for an follow up ?!
There was a problem hiding this comment.
Yes, I would love to use the consolidated test framework, but upbound-style E2E API is not yet available upstream.
There was a problem hiding this comment.
Great idea. Opened #368 to track it as a follow-up. I've kept this PR's --verify at a smoke-level 200; behavioral suites against the running control plane are the natural next step (pairs with the CPU-model idea, for something meaningful to assert against). Thanks!
| template: | ||
| spec: | ||
| containers: | ||
| - name: engine |
There was a problem hiding this comment.
are we sure that an override of this will also test something we bumped recently ? or is this followup then ?! disaggregation with EPP and then we have multiple container involved eg. https://github.com/modelplaneai/modelplane/blob/main/functions/compose-model-replica/function/routing.py#L60-L61 https://github.com/modelplaneai/modelplane/blob/main/functions/compose-model-replica/function/routing.py#L523 recently bumped because it was not working with Anthropic message style .. eg. #360
There was a problem hiding this comment.
Good question — split answer:
- The recent Unified-through-EPP routing and the Anthropic
/v1/messagessurface (feat(docs): Serve the Anthropic Messages API (Claude Code) end to end #360) are exercised: the mock is a Unified engine, soa7d28c3routes it through the composed InferencePool + endpoint-picker + HTTPRoute, and--verifyasserts 200 on both/v1/chat/completionsand/v1/messagesthrough that path. - Disaggregation (PrefillDecode — the pd-sidecar, two-phase engines, disagg EPP config, multiple containers) is not covered here — a single Unified engine doesn't touch it. That's a genuine follow-up, already listed under "Not tested" in the harness README.
Modelplane's getting started needs a cloud provider and GPUs, so there was no way to run the full path — publish capacity, register a cluster, deploy a model, route a request through the control-plane gateway — on a laptop. This adds `nix run .#e2e-local`, which brings that path up on two local kind clusters with no cloud and no GPU. Two clusters mirror a real deployment: a control-plane cluster (crossplane, the Configuration, the InferenceGateway) managed by `crossplane project run`, and a workload cluster registered via source: Existing where the serving stack and model run. One cluster won't do — the control-plane InferenceGateway (Traefik) and the workload ServingStack (Envoy) both install the Gateway API CRDs and collide on the cluster-scoped CRDs, wedging the gateway. Two primitives keep it cloud- and GPU-free. source: Existing registers the workload cluster via a kubeconfig Secret instead of provisioning EKS/GKE/Nebius. claim: DRA against a vendored fake GPU driver (kubernetes-sigs/dra-example-driver) binds the engine's ResourceClaim on a GPU-less node, so the real DRA allocation path runs; the fleet scheduler rejects an engine whose only device is Synthetic. The engine is a mock server, so the pod goes Ready and answers both /v1/chat/completions (OpenAI) and /v1/messages (Anthropic), the way a vLLM server does, without a real model. run.sh does the cross-cluster setup `crossplane project run` flags can't express. Two parts are load-bearing and non-obvious. Both MetalLB pools derive from the detected kind Docker subnet, not a hardcoded 172.18: kind bumps to 172.19+ when earlier networks hold 172.18, and an off-subnet LoadBalancer IP silently breaks control-plane-to-workload routing. And the getting-started RBAC prerequisites go in via --init-resources — crossplane needs them before it can compose the InferenceGateway's native cluster resources, or it wedges on "failed waiting for informer to sync". run.sh strips that file's leading comment-only document, which --init-resources rejects where kubectl skips it. By default it applies and returns, leaving the clusters up to poke. `--verify` instead waits for the ModelService, routes a request through the gateway, and asserts a 200 — exiting non-zero on failure — so the same command a developer runs is the one that gates CI. The Configuration depends on ~10 cloud providers that only provision clusters, unused when the workload cluster is registered via source: Existing. lean-control-plane.yaml (also an init resource) trims them: an ImageConfig points a scale-to-zero DeploymentRuntimeConfig at the xpkg.upbound.io/upbound/provider- prefix so those controllers install but run no pods, and a narrowed activation policy replaces the Helm chart's catch-all so only the kubernetes and helm managed resources activate. The local control plane then runs two provider controllers instead of thirteen, with tens of managed-resource CRDs active instead of hundreds. Signed-off-by: Yury Tsarev <yury@upbound.io>
The composition functions have unit tests, but nothing exercised the rendered resources on a real cluster in CI. The failures that only show up at runtime — missing RBAC, cross-cluster routing, provider readiness, teardown ordering — could land unnoticed. This adds an E2E workflow that runs hack/local-e2e via `nix run .#e2e-local -- --verify`, the same command developers run locally, so a green run in either place means the same thing. It's gated behind the `test-e2e` PR label rather than run on every push: two kind clusters plus the serving stack take 15-20 minutes, too long for the default PR path. Unlike the package push it needs no cloud or registry credentials, so it can gate a PR on its own. The job reclaims runner disk (two clusters and the serving stack don't fit the default free space), collects kind logs and resource status as artifacts on failure, and always tears both clusters down. Signed-off-by: Yury Tsarev <yury@upbound.io>
ce57422 to
7164870
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
hack/local-e2e/run.sh:248
- The verify pods use deterministic names (e2e-verify-oai-1..10 / e2e-verify-anthropic) while
kubectl runerrors are ignored. If a previous run leaves any of these pods behind, subsequent runs can fail to create the pod and then read stale logs from the old pod, potentially causing false passes or confusing flakiness. Use a per-run unique suffix in the pod name so each attempt always targets a fresh pod/log stream.
# OpenAI /v1/chat/completions, retried: the address can publish a moment before
# the cross-cluster route is serving, and a slower CI runner widens that gap.
oai='{"model":"'"$svc"'","messages":[{"role":"user","content":"ping"}]}'
code=""
for attempt in $(seq 1 10); do
Description of your changes
Modelplane has no end-to-end test in the repo. Unit tests cover what each composition function renders, but nothing checks that the composed resources actually reconcile and serve on a real cluster. The one path that exercises the full flow (publish capacity, register a cluster, deploy a model, serve a request) is getting started, which provisions EKS/GKE/Nebius and needs GPUs, so it can't run on a laptop or gate CI without a cloud account and accelerators. The failures that only surface at runtime (missing RBAC, cross-cluster routing, provider readiness, teardown ordering) have had nothing to catch them.
This adds
nix run .#e2e-local: the whole path on two localkindclusters.InferenceClusterregisters the workload cluster from a kubeconfig Secret (source: Existing) instead of provisioning EKS/GKE/Nebius; theInferenceClassadvertises a fakegpu.example.comdevice (claim: DRA, vendored dra-example-driver) that binds on a GPU-less node; and theModelDeploymentruns a mock OpenAI server in place of a real engine, so the pod goes Ready without a real model.nix run .#e2e-local -- --verifybrings it up, waits for the ModelService, and asserts a live 200. You run it locally; CI runs the identical command. The workflow is a thin wrapper (disk, cache, diagnostics, teardown) with no test logic of its own.test-e2ePR label, not every push (a run is 15-20 minutes). No secrets, so it can gate a PR on its own.nix run .#e2e-local -- --verifybrings both clusters up, asserts the endpoint serves (it curls from a pod on the control plane and checks the status), and leaves the whole platform queryable, no cloud in sight:Scope. Stops short of real token generation (mock engine), GPU drivers/CUDA (fake DRA), cloud provisioning, and multi-node serving.
hack/local-e2e/README.mdhas the design and a tested/not-tested table; CONTRIBUTING gains a note placing the two layers (unit vianix flake check, integration vianix run .#e2e-local).Alternatives ruled out.
nix run .#e2e-local -- --verifypasses both locally and in CI (green run: https://github.com/ytsarev/modelplane-public/actions/runs/29854197681).I have:
nix flake check(or./nix.sh flake check) and made sure it passes.Added or updated tests covering any composition function changes.(nofunctions/changes)git commit -s.