Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
146 commits
Select commit Hold shift + click to select a range
a8d7913
K0: exercise the kubeai backend against a real cluster
Erotemic Sep 24, 2026
711048b
K1: front the kubeai backend with the LiteLLM gateway
Erotemic Sep 24, 2026
b7bc3e8
Remove the unused profile-era KubeAI renderer
Erotemic Sep 24, 2026
e84c131
K2+K4: strict pod residency and shared crash diagnosis for kubeai
Erotemic Sep 24, 2026
791f4c8
K5: one served-name rule, shared GPU count, env on kubeai
Erotemic Sep 24, 2026
bbbe610
Journal: backend unification K0-K2, K4, K5
Erotemic Sep 24, 2026
748e2ba
Merge branch 'main' into dev/backend-unification
Erotemic Sep 24, 2026
2c51376
G-A/B: move naming rules and the gateway's module code out of compose.py
Erotemic Sep 24, 2026
ba93ad9
G-C: a Gateway class owns the front door's state
Erotemic Sep 24, 2026
4d849eb
Merge branch 'main' into dev/backend-unification
Erotemic Sep 24, 2026
96ffaf5
G-D: render_compose's front door becomes gateway.render_front_door
Erotemic Sep 24, 2026
f010ccf
Record the gateway extraction; drop two dead names in the controller
Erotemic Sep 24, 2026
0101a2d
Lesson: size-only test files should be sparse and cleaned up
Erotemic Sep 24, 2026
af2e8fc
Make room for leased demand: idle keep-warm models give way on KubeAI
Erotemic Sep 24, 2026
08e61d5
Serialize every use of the shared ledger connection
Erotemic Sep 24, 2026
f03f6e9
TUI: stop repainting unchanged panes; batch log drawing; report stalls
Erotemic Sep 24, 2026
13d8667
Merge branch 'main' into dev/backend-unification
Erotemic Sep 24, 2026
8120605
Merge branch 'main' into dev/backend-unification
Erotemic Sep 24, 2026
da71846
Changelog: the TUI stops repainting what did not change
Erotemic Sep 24, 2026
4feff29
TUI: an activity line for running actions; faster launch and quit
Erotemic Sep 24, 2026
a3d18c1
Switch the CLI from scriptconfig to kwconf
Erotemic Sep 24, 2026
4def7e7
Remove the uv exclude-newer cutoff
Erotemic Sep 25, 2026
271d645
TUI: one Clean up key in the footer; gc --forget; log CLI equivalents
Erotemic Sep 25, 2026
d672bfa
TUI: shorten the four pane descriptions
Erotemic Sep 25, 2026
3fd3a37
TUI: show endpoints|models and leases|deployments as tabs
Erotemic Sep 25, 2026
fe88f74
Docs: Compose/KubeAI parity matrix and a roadmap to close the gaps
Erotemic Sep 26, 2026
d6a19c4
P1a: KubeAI acquires go through admission, as Compose's do
Erotemic Sep 26, 2026
d3ee494
Docs: a work queue for backend parity, ending in a UX audit loop
Erotemic Sep 26, 2026
184289c
P1b: one acquire path; the pre-admission branch is gone
Erotemic Sep 26, 2026
64f1e59
Queue: record the P1b commit
Erotemic Sep 26, 2026
c2d393b
P2: ps, logs, status and the TUI read the backend, on either backend
Erotemic Sep 26, 2026
46b49b7
Queue: record the P2 commit
Erotemic Sep 26, 2026
7a322c1
P3: the gateway in front of a cluster is the compose gateway
Erotemic Sep 26, 2026
487cab5
Queue: record the P3 commit
Erotemic Sep 26, 2026
1a4e4b2
P6: a parity suite, one test per row the matrix marks same
Erotemic Sep 26, 2026
c430caf
Queue: record the P6 commit
Erotemic Sep 26, 2026
94c263d
P4: KubeAI picks a resource profile by GPU size
Erotemic Sep 26, 2026
08dccd9
Queue: record the P4 commit
Erotemic Sep 26, 2026
49e70dd
P5: the gateway can run inside the cluster
Erotemic Sep 26, 2026
daf2476
Queue: record the P5 commit
Erotemic Sep 26, 2026
76c84aa
UX: "free a GPU first" only when a free GPU would have helped
Erotemic Sep 26, 2026
7fe76b3
Plan doc: a generic model name in the naming example
Erotemic Sep 26, 2026
e1c68f3
Docs: the README and two docs describe the current CLI
Erotemic Sep 26, 2026
00dbed1
Docs: mark the pre-leasing recipes as such; drop dead Makefile targets
Erotemic Sep 26, 2026
9eba980
UX: `logs --no-color` works
Erotemic Sep 26, 2026
ce48cc4
UX: Open WebUI runs as its data directory's owner
Erotemic Sep 26, 2026
977fa4e
UX: status says "pending" while a change is being applied
Erotemic Sep 26, 2026
6629c72
UX: the TUI picks up catalog edits made outside it
Erotemic Sep 26, 2026
0fc3f5d
UX: help and narration say what is true on both backends
Erotemic Sep 26, 2026
cc2907d
UX: failures name their cause, not a traceback
Erotemic Sep 26, 2026
fdc57c8
UX: an unreachable cluster says why, in one line
Erotemic Sep 26, 2026
be0b1fa
UX: the TUI works at 80x24 with the runtime pane open
Erotemic Sep 26, 2026
8bd07c8
UX: the TUI's API tab never shows the master key
Erotemic Sep 26, 2026
224501a
UX: the TUI says why a runtime command failed, and shows engine colors
Erotemic Sep 26, 2026
a62e5ae
UX: the README's first run works on a host with no GPU
Erotemic Sep 26, 2026
a8e66e9
Docs: the KubeAI profile example requests a GPU, as the README warns …
Erotemic Sep 26, 2026
0afbbaa
UX: the TUI's "Clean up" is "Clear finished"
Erotemic Sep 26, 2026
817d771
UX: `test` and `env` work on kubeai; readable TTLs; clean pipes
Erotemic Sep 26, 2026
aff378e
UX: kubeai narration and doctor say what they mean; an audit script
Erotemic Sep 26, 2026
96fc442
UX: kubeai's doctor checks the gateway's Docker too
Erotemic Sep 26, 2026
9696fa0
Docs: the manual describes the TUI's runtime pane and API tab as they…
Erotemic Sep 26, 2026
9bd3154
ps and the TUI show instance start times in local time
Erotemic Sep 26, 2026
4c77237
TUI: one gateway URL authority, tab keys, a sidebar that uses the width
Erotemic Sep 26, 2026
052fb36
TUI: the runtime pane grows when the terminal does
Erotemic Sep 26, 2026
91d36a2
A released stop deployment is reclaimed, not missing
Erotemic Sep 26, 2026
6495f5c
release: a summary that reads as a sentence
Erotemic Sep 26, 2026
d88a50c
queue: the abbreviation finding is decided, not open
Erotemic Sep 26, 2026
d4872ae
release/renew: a missing env-file is a message, not a traceback
Erotemic Sep 26, 2026
1692fdb
dev: the second k3s node reclaims a fixed 1Gi under disk pressure
Erotemic Sep 26, 2026
548486e
ps: GPUS says cpu or all, as leases does
Erotemic Sep 26, 2026
ff38cd3
queue: the UX audit passes (pass 8 found nothing new)
Erotemic Sep 26, 2026
7833668
queue: handover for the operator; the full kubeai e2e passes
Erotemic Sep 26, 2026
376784b
Decide /dev/shm and the pre-leasing recipes (queue 8a, 8b)
Erotemic Sep 26, 2026
1f58cc6
queue: reopen with review part 1 (replica residency, backend protocol…
Erotemic Sep 26, 2026
4555fcd
queue: add review part 2 (items 14-22); roadmap status is review hard…
Erotemic Sep 26, 2026
35be92c
Residency asks about deployments; one backend protocol; replica poiso…
Erotemic Sep 26, 2026
ee7bd7f
Reclaim only for capacity, only a victim that could help
Erotemic Sep 26, 2026
2face98
Keep the approved render until its publication completes
Erotemic Sep 26, 2026
8e4d9ed
Secret rotation restores the old key on any failure before apply
Erotemic Sep 26, 2026
b1e5a41
Retire routes before their upstreams go
Erotemic Sep 26, 2026
2473e95
Coalescing prefers what costs least: LIVE, then resident IDLE
Erotemic Sep 26, 2026
b2d003a
routes seed refuses to redefine an alias; one route-registry API
Erotemic Sep 26, 2026
9fa0116
A preview writes no secrets
Erotemic Sep 26, 2026
7e53162
Public seams instead of private reaches: lock, profile, marker
Erotemic Sep 26, 2026
e9e1cf9
Docs follow the hardened semantics; the replacement-gap poison case
Erotemic Sep 26, 2026
de7dea8
queue: the replacement-gap test fails on the old code too
Erotemic Sep 26, 2026
2d8785b
dev: journal and a lesson for the review hardening
Erotemic Sep 26, 2026
b1040f1
docs: date the review hardening 2026-09-26, the day it happened
Erotemic Sep 26, 2026
b1f5f62
queue: items 11-22 done; roadmap status: review hardening done
Erotemic Sep 26, 2026
0da932b
queue: reopen 11, 14, 15, 16, 21 and add 23 from the re-review
Erotemic Sep 26, 2026
4b8783b
KubeAI settles its host gateway after an interrupted apply
Erotemic Sep 26, 2026
4476573
Rotation keeps the old key when the apply cleanly misses the runtime
Erotemic Sep 27, 2026
768d924
An explicit re-approval is durable across its own partial apply
Erotemic Sep 27, 2026
b8b3542
Replicas: judged one by one, and serving is not residency
Erotemic Sep 27, 2026
55c1f96
Reclaim fails closed on scheduling it does not model
Erotemic Sep 27, 2026
30a62bb
A race-time routes seed conflict leaves no pending publication
Erotemic Sep 27, 2026
122f1b4
tests: the publish_change spy passes preflight through
Erotemic Sep 27, 2026
2f9adc4
Backend hooks: required state, common operations, typed capabilities
Erotemic Sep 27, 2026
abcd03d
queue: re-review items closed; roadmap and parity rows follow
Erotemic Sep 27, 2026
fde855e
dev: journal for the re-review pass
Erotemic Sep 27, 2026
4d54d60
queue: final closure items 24-27 (rotation x dynamic routing first)
Erotemic Sep 27, 2026
45e0e1c
Rotation completes under a running dynamic-routing gateway
Erotemic Sep 27, 2026
3abb2ff
The in-process backends keep last_planned_digest; the guard fails closed
Erotemic Sep 27, 2026
cfe6512
Docs say what leases health reports; the seed --replace race is recorded
Erotemic Sep 27, 2026
5337815
secrets rotate waits for the old key's rejection behind a rollout
Erotemic Sep 27, 2026
e8e9d42
queue: campaign 2 (external endpoints, access above leasing), queued …
Erotemic Sep 27, 2026
79a96e6
roadmap: review hardening closed (queue 11-27)
Erotemic Sep 27, 2026
0082538
queue: log everything still open outside the numbered items
Erotemic Sep 27, 2026
7e0e0b3
queue: campaign 2 design review folded in (item 28 decisions first)
Erotemic Sep 27, 2026
58df839
paths flags state directories another user (root) wrote
Erotemic Sep 27, 2026
fd11041
Campaign 2 design: published endpoints, derived routes, one access tr…
Erotemic Sep 27, 2026
bc390ad
ResolvedEndpoint: what an endpoint means, above its lease request
Erotemic Sep 27, 2026
131dd26
External endpoints in the catalog: an external: target, not an engine
Erotemic Sep 27, 2026
cbfc1da
One terminology: endpoint alias, upstream model, deployment id
Erotemic Sep 27, 2026
862c178
Published endpoint definitions outlive quiescence
Erotemic Sep 27, 2026
e697ccd
One GatewayRoute; gateway routes derived from the published catalogs
Erotemic Sep 27, 2026
2ac9a47
External keys by name on both gateways; a real-LiteLLM external e2e
Erotemic Sep 27, 2026
93823c6
Access above leasing: `infer-stack access`, typed connection info
Erotemic Sep 27, 2026
3562bcf
Show external endpoints as external: status, TUI catalog
Erotemic Sep 27, 2026
19fc8fd
External e2e covers access; bundles survive a member's redefinition
Erotemic Sep 27, 2026
501688e
Document external endpoints and access; audit covers access
Erotemic Sep 27, 2026
8797fdd
routes seed --replace is compare-and-swap; CLI decisions use typed me…
Erotemic Sep 27, 2026
09b72fa
External e2e runs on KubeAI's gateway too
Erotemic Sep 27, 2026
21ade9a
known-limitations: the in-cluster gateway routes only
Erotemic Sep 27, 2026
2af9c70
queue: campaign 2 review items 42-50
Erotemic Sep 27, 2026
df62475
Published endpoints commit with publication intent, never before
Erotemic Sep 27, 2026
852753b
Access is ready only once its routes are published
Erotemic Sep 27, 2026
7df1352
Missing keys refuse every publication; FrontDoorControl is the whole …
Erotemic Sep 27, 2026
5fc6e6c
Pinning protects every resident alias, not only catalog-defined ones
Erotemic Sep 27, 2026
7d158fe
routes list says it is desired state; api_base parsed strictly; no pr…
Erotemic Sep 27, 2026
93285f5
Journal and queue: campaign 2 review round closed
Erotemic Sep 27, 2026
7a01637
Add Kubernetes integration setup CLI
Erotemic Sep 28, 2026
2608d62
Keep Kubernetes integration distribution neutral
Erotemic Sep 28, 2026
81415c6
Add reversible Kubernetes Compose node handoff
Erotemic Sep 28, 2026
882758c
Add Kubernetes readiness setup and safe backend recovery transitions
Erotemic Oct 1, 2026
bc47baa
Secure K3s provisioning and correct Kubernetes readiness
Erotemic Oct 2, 2026
aad587a
Add efficient Kubernetes TUI monitoring and controls
Erotemic Oct 2, 2026
aee7d97
Respect KubeAI Model name limits with stable hashed shortening
Erotemic Oct 2, 2026
4a2c80b
Refresh GPU runtime canaries and clarify worker join readiness
Erotemic Oct 2, 2026
2dc083f
Separate KubeAI declaration, replica readiness and generation evidence
Erotemic Oct 2, 2026
51fcad9
Expose only supported KubeAI endpoint launch controls
Erotemic Oct 2, 2026
a3e08fd
Preserve Kubernetes generation evidence across polling and late respo…
Erotemic Oct 2, 2026
34e2c59
Add private K3s onboarding and named GPU worker acceptance
Erotemic Oct 2, 2026
97244dc
Document private worker onboarding and scoped GPU acceptance
Erotemic Oct 2, 2026
2c44ed3
Fix GPU holder scan races
Erotemic Oct 2, 2026
8cd0228
Merge branch 'dev/backend-unification' of github.com:AIQ-Kitware/infe…
Erotemic Oct 2, 2026
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
4 changes: 0 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@ generated/*
!generated/kubeai/
generated/kubeai/*
!generated/kubeai/.gitignore
!examples/
!examples/single-node/
!examples/single-node/config.yaml
!examples/single-node/models.yaml

# rsync target for e2e harness reports (developer artifacts, not source)
e2e-report-*/
Expand Down
351 changes: 351 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

28 changes: 6 additions & 22 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
PYTHON ?= python
EXAMPLE ?= single-node

init:
$(PYTHON) manage.py init

render:
$(PYTHON) manage.py render

deploy:
$(PYTHON) manage.py deploy
# Optional extra Helm values; the common case infers GPU profiles.
VALUES ?=

status:
$(PYTHON) manage.py status

smoke-test:
$(PYTHON) manage.py smoke-test

example-single-node:
cp examples/single-node/config.yaml ./config.yaml
cp examples/single-node/models.yaml ./models.yaml
@echo "Copied single-node example into repo root. Edit hostname/model ids as needed."
render:
$(PYTHON) manage.py render

bootstrap-k3s:
bash scripts/bootstrap_k3s.sh
$(PYTHON) manage.py kube bootstrap --provider=k3s --apply

install-kubeai:
bash scripts/install_kubeai.sh generated/kubeai/kubeai-values.yaml kubeai

bootstrap-single-node: example-single-node bootstrap-k3s
@echo "Single-node example copied and K3s bootstrapped. Next: python manage.py render && python manage.py deploy"
$(PYTHON) manage.py kube install --apply $(if $(VALUES),--values "$(VALUES)",)

port-forward-kubeai:
kubectl -n kubeai port-forward svc/kubeai 8000:80
1,363 changes: 482 additions & 881 deletions README.md

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions dev/e2e_tests/kubeai-cpu-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# KubeAI on a GPU-less machine, for exercising the kubeai backend for real.
# The chart's `cpu` profile runs vLLM's CPU image (needs AVX-512). Sized for
# a sub-1B model; raise the requests for anything larger.
resourceProfiles:
cpu:
imageName: "cpu"
requests:
cpu: 8
memory: "16Gi"
# Half the guest's 120 cores: only one such Model fits at a time, which is
# how dev/kubeai_e2e.sh's make-room check forces a scheduling conflict.
cpu-half:
imageName: "cpu"
requests:
cpu: 64
memory: "16Gi"
# Two "GPU sizes" on CPU, for the sizing check (E2E_SIZED=1 in
# dev/kubeai_e2e.sh): each selects the nodes labelled with one fake GPU
# product, as a real profile selects nvidia.com/gpu.product. `sized-80g`
# tolerates dev/k3s_agent_container.sh's taint, so it lands on that node.
sized-24g:
imageName: "cpu"
requests:
cpu: 8
memory: "16Gi"
nodeSelector:
nvidia.com/gpu.product: "FAKE-24G"
sized-80g:
imageName: "cpu"
requests:
cpu: 8
memory: "16Gi"
nodeSelector:
nvidia.com/gpu.product: "FAKE-80G"
tolerations:
- key: infer-stack.test/simulated
operator: Exists
effect: NoSchedule
270 changes: 270 additions & 0 deletions dev/external_e2e.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
#!/usr/bin/env bash
# End-to-end test for external endpoints against a REAL LiteLLM gateway.
#
# No GPU, no model weights: the compose backend runs only its front door, and
# the "external" server is infer-stack's own mock OpenAI server on this host,
# which accepts one bearer key and records every request. Runs on the guest
# VM (docker, the pinned litellm and postgres images).
#
# ./dev/external_e2e.sh
#
# For each routing mode (static, then dynamic):
# 1. an external endpoint whose key has no value is refused before publishing;
# 2. `routes seed` publishes it and the gateway routes the alias to the mock,
# sending the key from the managed .env by name (`os.environ/NAME`);
# 3. the key changes: `env` says an apply is needed, the old key is refused
# by the upstream until `apply`, the new key is sent after it;
# 4. `routes prune` unpublishes it and the alias stops routing;
# 5. `access` of the external endpoint alone: no lease, no lease id in the
# env-file, a client that knows only the env-file reaches it;
# 6. `access` of a managed endpoint alone (llm-d-inference-sim, on CPU):
# one lease, the same env-file shape;
# 7. `access` of a mixed bundle: one lease for the managed member only;
# `release --env-file` releases it and the external route stays;
# 8. the managed alias becomes external after release and eviction:
# same alias, same workflow, no lease.
#
# Knobs (env):
# E2E_MODES routing modes to run (default "static dynamic")
# E2E_PORT the mock's port (default 18911)
# E2E_SIM the simulator image (default ghcr.io/llm-d/llm-d-inference-sim:v0.9.0)
# E2E_BACKEND compose (default) or kubeai: the external route through
# KubeAI's host gateway (phases 1-5; 6-8 need the simulator,
# which runs on Compose only). Needs KUBECONFIG and the chart.
set -euo pipefail

MODES="${E2E_MODES:-static dynamic}"
PORT="${E2E_PORT:-18911}"
SIM="${E2E_SIM:-ghcr.io/llm-d/llm-d-inference-sim:v0.9.0}"
BACKEND="${E2E_BACKEND:-compose}"
HOST_IP="$(ip -4 -o addr show docker0 | awk '{print $4}' | cut -d/ -f1)"
[ -n "$HOST_IP" ] || { echo 'no docker0 address' >&2; exit 1; }

fail() { echo "FAIL: $*" >&2; exit 1; }

rm_work() { # Postgres owns its data dir as its own uid
[ -d "$1/data" ] && docker run --rm -v "$1/data:/d" --entrypoint sh postgres:16.8 \
-c 'rm -rf /d/postgres-litellm /d/postgres-open-webui' >/dev/null 2>&1
rm -rf "$1"
}

MOCK_PID=''
start_mock() { # start_mock KEY
stop_mock
cat > "$WORK/mock.yaml" <<EOF
models:
Upstream/Model: {ability: 0.9}
EOF
python -m infer_stack mock serve --config_fpath "$WORK/mock.yaml" \
--host 0.0.0.0 --port "$PORT" --api_key "$1" >"$WORK/mock.log" 2>&1 &
MOCK_PID=$!
for _ in $(seq 50); do
curl -sf "http://127.0.0.1:$PORT/health" >/dev/null && return 0
sleep 0.2
done
fail "mock server did not start: $(cat "$WORK/mock.log")"
}
stop_mock() {
if [ -n "$MOCK_PID" ]; then kill "$MOCK_PID" 2>/dev/null || true; wait "$MOCK_PID" 2>/dev/null || true; fi
MOCK_PID=''
}

last_bearer() { # the Authorization the mock last received
curl -sf "http://127.0.0.1:$PORT/__mock__/requests" | python -c '
import json, sys
reqs = [r for r in json.load(sys.stdin)["requests"]
if r.get("path", "").endswith("/chat/completions")]
headers = {k.lower(): v for k, v in reqs[-1]["headers"].items()} if reqs else {}
print(headers.get("authorization", ""))'
}

chat() { # chat ALIAS -> http status; body in $WORK/chat.json
local base key
base="$(run_is env OPENAI_BASE_URL)"
key="$(run_is env LITELLM_MASTER_KEY)"
curl -s -o "$WORK/chat.json" -w '%{http_code}' "$base/chat/completions" \
-H "Authorization: Bearer $key" -H 'Content-Type: application/json' \
-d "{\"model\": \"$1\", \"max_tokens\": 8,
\"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]}" || true
}

chat_env() { # chat_env ENV_FILE ALIAS -> http status, as a card would
(
# shellcheck disable=SC1090
source "$1"
var="INFER_STACK_ENDPOINT_$(echo "$2" | tr '[:lower:]-' '[:upper:]_')"
curl -s -o "$WORK/chat.json" -w '%{http_code}' "$OPENAI_BASE_URL/chat/completions" \
-H "Authorization: Bearer $OPENAI_API_KEY" -H 'Content-Type: application/json' \
-d "{\"model\": \"${!var}\", \"max_tokens\": 8,
\"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]}" || true
)
}

leases_active() { # how many ACTIVE leases the ledger holds
run_is leases --json | python -c '
import json, sys
print(sum(1 for le in json.load(sys.stdin).get("leases", []) if le.get("state") == "active"))'
}

chat_until() { # chat_until ALIAS STATUS: routes settle after apply
local got=''
for _ in $(seq 60); do
got="$(chat "$1")"
[ "$got" = "$2" ] && return 0
sleep 2
done
fail "chat $1: wanted HTTP $2, got $got: $(cat "$WORK/chat.json")"
}

run_mode() {
local mode=$1
WORK="$(mktemp -d /tmp/infer-stack-external-e2e.XXXXXX)"
IS_ENV="INFER_STACK_CONFIG_DIR=$WORK/config INFER_STACK_DATA_DIR=$WORK/data"
run_is() { env $IS_ENV infer-stack "$@"; }
echo "== [$mode] work dir: $WORK"
mkdir -p "$WORK/config"
run_is config set backend "$BACKEND" >/dev/null
if [ "$mode" = dynamic ]; then run_is config set dynamic_routing true >/dev/null; fi
# The runbook's own catalog: nothing external.
cat > "$WORK/config/catalog.yaml" <<EOF
models: {}
endpoints: {}
EOF
cat > "$WORK/external.yaml" <<EOF
endpoints:
remote:
external:
api_base: http://$HOST_IP:$PORT/v1
model: Upstream/Model
api_key_env: E2E_REMOTE_KEY
EOF
start_mock key-one

echo "== [$mode] 1. a key with no value is refused before publishing"
if run_is routes seed "$WORK/external.yaml" --yes >"$WORK/out" 2>&1; then
fail 'seed without the key succeeded'
fi
grep -q 'infer-stack env E2E_REMOTE_KEY=' "$WORK/out" || fail "$(cat "$WORK/out")"

echo "== [$mode] 2. publish; the gateway sends the key by name"
run_is env E2E_REMOTE_KEY=key-one >/dev/null
run_is routes seed "$WORK/external.yaml" --yes
run_is routes list
run_is routes list --json | grep -q '"origin": "external"' || fail 'not listed as external'
if grep -rq --include=docker-compose.yml 'key-one' "$WORK/data"; then
fail 'the key value is in the compose file'
fi
chat_until remote 200
[ "$(last_bearer)" = 'Bearer key-one' ] || fail "sent $(last_bearer)"

echo "== [$mode] 3. the key changes"
start_mock key-two # the upstream rotated
chat_until remote 401
run_is env E2E_REMOTE_KEY=key-two | tee "$WORK/out"
grep -q 'after `infer-stack apply`' "$WORK/out" || fail 'env did not say apply'
run_is apply --yes
chat_until remote 200
[ "$(last_bearer)" = 'Bearer key-two' ] || fail "sent $(last_bearer)"

echo "== [$mode] 4. prune unpublishes it"
run_is routes prune --yes
if run_is routes list --json | grep -q '"remote"'; then fail 'still listed after prune'; fi
chat_until remote 400

if [ "$BACKEND" != compose ]; then
echo "== [$mode] 5. access an external endpoint: no lease"
cp "$WORK/external.yaml" "$WORK/config/catalog.yaml"
run_is access remote --yes --env-file "$WORK/remote.env"
if grep -q INFER_STACK_LEASE_ID "$WORK/remote.env"; then fail 'a lease id without a lease'; fi
[ "$(chat_env "$WORK/remote.env" remote)" = 200 ] || fail "remote: $(cat "$WORK/chat.json")"
run_is stack down >/dev/null 2>&1 || true
stop_mock
rm_work "$WORK"
echo "== [$mode/$BACKEND] PASS (phases 1-5)"
return 0
fi
# The runbook's catalog now names a managed endpoint, the external one,
# and a bundle of both.
cat > "$WORK/config/catalog.yaml" <<EOF
models:
smol: {source: hf://HuggingFaceTB/SmolLM2-135M-Instruct}
endpoints:
local:
engine: vllm
model: smol
runtime:
image: $SIM
max_model_len: 2048
simulator: {kind: llm-d-sim, mode: echo}
remote:
external:
api_base: http://$HOST_IP:$PORT/v1
model: Upstream/Model
api_key_env: E2E_REMOTE_KEY
bundles:
pair: [local, remote]
EOF

echo "== [$mode] 5. access an external endpoint: no lease"
run_is access remote --yes --env-file "$WORK/remote.env"
if grep -q INFER_STACK_LEASE_ID "$WORK/remote.env"; then fail 'a lease id without a lease'; fi
[ "$(leases_active)" = 0 ] || fail 'external access took a lease'
[ "$(chat_env "$WORK/remote.env" remote)" = 200 ] || fail "remote: $(cat "$WORK/chat.json")"
run_is release --env-file "$WORK/remote.env" | grep -q 'nothing to release' \
|| fail 'release of a lease-less env-file'

echo "== [$mode] 6. access a managed endpoint: one lease"
run_is access local --yes --timeout 300 --env-file "$WORK/local.env"
grep -q INFER_STACK_LEASE_ID "$WORK/local.env" || fail 'managed access without a lease id'
[ "$(chat_env "$WORK/local.env" local)" = 200 ] || fail "local: $(cat "$WORK/chat.json")"
run_is release --env-file "$WORK/local.env" --yes

echo "== [$mode] 7. a mixed bundle: one lease, for the managed member"
run_is access pair --yes --timeout 300 --env-file "$WORK/pair.env" --json > "$WORK/pair.json"
python - "$WORK/pair.json" <<'PY' || fail "pair: $(cat "$WORK/pair.json")"
import json, sys
d = json.load(open(sys.argv[1]))
assert d['lease_id'] and d['external'] == ['remote'], d
assert set(d['descriptor']['endpoints']) == {'local', 'remote'}, d
PY
[ "$(leases_active)" = 1 ] || fail 'a mixed bundle took other than one lease'
[ "$(chat_env "$WORK/pair.env" local)" = 200 ] || fail "pair/local: $(cat "$WORK/chat.json")"
[ "$(chat_env "$WORK/pair.env" remote)" = 200 ] || fail "pair/remote: $(cat "$WORK/chat.json")"
run_is release --env-file "$WORK/pair.env" --yes
[ "$(leases_active)" = 0 ] || fail 'the bundle lease is still held'
[ "$(chat_env "$WORK/pair.env" remote)" = 200 ] || fail 'remote stopped routing after release'

echo "== [$mode] 8. the managed alias moves to an external server"
run_is evict --all --yes
python - "$WORK/config/catalog.yaml" <<'PY'
import sys, yaml
path = sys.argv[1]
cat = yaml.safe_load(open(path))
cat['endpoints']['local'] = {'external': dict(cat['endpoints']['remote']['external'])}
yaml.safe_dump(cat, open(path, 'w'), sort_keys=False)
PY
run_is access local --yes --env-file "$WORK/moved.env"
if grep -q INFER_STACK_LEASE_ID "$WORK/moved.env"; then fail 'moved alias took a lease'; fi
[ "$(chat_env "$WORK/moved.env" local)" = 200 ] || fail "moved: $(cat "$WORK/chat.json")"
[ "$(last_bearer)" = 'Bearer key-two' ] || fail "moved sent $(last_bearer)"

run_is stack down >/dev/null 2>&1 || true
stop_mock
rm_work "$WORK"
echo "== [$mode] PASS"
}

cleanup() {
status=$?
set +e
stop_mock
if [ -n "${WORK:-}" ] && [ -d "$WORK" ]; then
env $IS_ENV infer-stack stack down >/dev/null 2>&1
rm_work "$WORK"
fi
exit $status
}
trap cleanup EXIT

for mode in $MODES; do run_mode "$mode"; done
echo '== external endpoints e2e: PASS'
Loading
Loading