Skip to content

Add a local two-cluster end-to-end test (no cloud, no GPU) - #365

Open
ytsarev wants to merge 2 commits into
modelplaneai:mainfrom
ytsarev:local-e2e-synthetic
Open

Add a local two-cluster end-to-end test (no cloud, no GPU)#365
ytsarev wants to merge 2 commits into
modelplaneai:mainfrom
ytsarev:local-e2e-synthetic

Conversation

@ytsarev

@ytsarev ytsarev commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

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 local kind clusters.

  • No cloud, no GPU, no credentials. The InferenceCluster registers the workload cluster from a kubeconfig Secret (source: Existing) instead of provisioning EKS/GKE/Nebius; the InferenceClass advertises a fake gpu.example.com device (claim: DRA, vendored dra-example-driver) that binds on a GPU-less node; and the ModelDeployment runs a mock OpenAI server in place of a real engine, so the pod goes Ready without a real model.
  • Dev/CI parity. nix run .#e2e-local -- --verify brings 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.
  • Label-gated. Behind a test-e2e PR label, not every push (a run is 15-20 minutes). No secrets, so it can gate a PR on its own.
  • Real orchestration. Two clusters, because the control-plane and workload gateways both install the Gateway API CRDs and collide on one; the full ModelDeployment, ModelReplica, ModelEndpoint, ModelService chain; a DRA ResourceClaim actually bound on the workload cluster.

nix run .#e2e-local -- --verify brings 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:

$ nix run .#e2e-local -- --verify
...
==> ModelService address: http://172.19.255.200/ml-team/mock
==> verify attempt 1: HTTP 200
==> End to end OK: http://172.19.255.200/ml-team/mock served HTTP 200

$ kubectl get inferencegateway,inferencecluster,inferenceclass
NAME                                     GATEWAY   ADDRESS          SYNCED   READY
inferencegateway.modelplane.ai/default   Traefik   172.19.255.200   True     True
NAME                                   SOURCE     GATEWAY          SYNCED   READY
inferencecluster.modelplane.ai/local   Existing   172.19.255.100   True     True
NAME                                         SYNCED   READY
inferenceclass.modelplane.ai/synthetic-gpu   True     True

$ kubectl -n ml-team get modeldeployment,modelservice
NAME                                      REPLICAS   SYNCED   READY
modeldeployment.modelplane.ai/mock-demo   1          True     True
NAME                              ADDRESS                              SYNCED   READY
modelservice.modelplane.ai/mock   http://172.19.255.200/ml-team/mock   True     True

Scope. Stops short of real token generation (mock engine), GPU drivers/CUDA (fake DRA), cloud provisioning, and multi-node serving. hack/local-e2e/README.md has the design and a tested/not-tested table; CONTRIBUTING gains a note placing the two layers (unit via nix flake check, integration via nix run .#e2e-local).

Alternatives ruled out.

  • A declarative test runner like Chainsaw for the assertions: the hard part is the two-cluster bootstrap, which is irreducible shell a runner would only wrap, so it wasn't worth a third testing idiom for one scenario.
  • A small real model on CPU: exercises the inference engine, not Modelplane's orchestration, at a large cost in image size, weights, and runtime.

nix run .#e2e-local -- --verify passes both locally and in CI (green run: https://github.com/ytsarev/modelplane-public/actions/runs/29854197681).

I have:

  • Read and followed Modelplane's contribution process.
  • Run nix flake check (or ./nix.sh flake check) and made sure it passes.
  • Added or updated tests covering any composition function changes. (no functions/ changes)
  • Signed off every commit with git commit -s.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-local to 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 -- --verify command used locally.
  • Documents the new integration-test layer in hack/local-e2e/README.md and CONTRIBUTING.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.

Comment thread hack/local-e2e/manifests/10-inference-gateway.yaml Outdated
Comment thread hack/local-e2e/run.sh
Comment thread hack/local-e2e/run.sh Outdated
Comment thread hack/local-e2e/README.md

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Comment thread nix/apps.nix
Comment thread .github/workflows/e2e.yml
Comment thread hack/local-e2e/manifests/40-model-deployment.yaml Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread hack/local-e2e/run.sh
Comment thread hack/local-e2e/run.sh

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Comment thread hack/local-e2e/run.sh
Comment thread hack/local-e2e/run.sh Outdated
Comment thread CONTRIBUTING.md Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comment thread hack/local-e2e/run.sh Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread hack/local-e2e/dra-example-driver.yaml Outdated
Comment thread hack/local-e2e/README.md Outdated
Copilot AI review requested due to automatic review settings July 21, 2026 21:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_status assumes the caller-provided pod name is unique, but kubectl run errors are swallowed (|| true). If a previous run crashed and left an e2e-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 inside curl_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

  • ClusterRoleBinding is cluster-scoped; metadata.namespace should be omitted to avoid confusion and potential schema-validation failures.
kind: ClusterRoleBinding
metadata:
  name: dra-example-driver-role-binding
  namespace: dra-example-driver

Comment thread hack/local-e2e/dra-example-driver.yaml

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Comment thread hack/local-e2e/run.sh Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 run errors are ignored, the subsequent kubectl logs call 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 run can fail (and is ignored), and kubectl logs may 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")"

Comment thread hack/local-e2e/lean-control-plane.yaml Outdated
activate:
- "*.kubernetes.crossplane.io"
- "*.kubernetes.m.crossplane.io"
- "*.helm.crossplane.io"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread hack/local-e2e/run.sh
exit 0
fi

# --verify: project run returns once the config is healthy and the resources are

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder if we can run some python test-suites after the project is running in kind - perhaps good for an follow up ?!

@ytsarev ytsarev Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I would love to use the consolidated test framework, but upbound-style E2E API is not yet available upstream.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@haarchri haarchri Jul 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — split answer:

  • The recent Unified-through-EPP routing and the Anthropic /v1/messages surface (feat(docs): Serve the Anthropic Messages API (Claude Code) end to end #360) are exercised: the mock is a Unified engine, so a7d28c3 routes it through the composed InferencePool + endpoint-picker + HTTPRoute, and --verify asserts 200 on both /v1/chat/completions and /v1/messages through 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.

ytsarev added 2 commits July 24, 2026 13:01
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 run errors 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

@ytsarev
ytsarev requested a review from haarchri July 24, 2026 11:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants