[SMG] Expand K8s integration tests: cross-namespace, lifecycle, multi-model (#24375)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e8d57e7243
commit
8b9ff4a68c
@@ -0,0 +1,79 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-cluster
|
||||||
|
namespace: smg-test
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: smg-gateway-cluster
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: smg-gateway-cluster
|
||||||
|
spec:
|
||||||
|
serviceAccountName: smg-gateway-cluster
|
||||||
|
containers:
|
||||||
|
- name: gateway
|
||||||
|
image: smg-gateway:test
|
||||||
|
imagePullPolicy: Never
|
||||||
|
# No --service-discovery-namespace: watch all namespaces (Api::all
|
||||||
|
# in service_discovery.rs:279). Requires the ClusterRole defined in
|
||||||
|
# rbac-cluster-scoped.yaml.
|
||||||
|
#
|
||||||
|
# Selector includes `cross-ns-test=true` (in addition to the usual
|
||||||
|
# app=fake-worker) so this gateway only registers workers deployed
|
||||||
|
# by test_cross_namespace.py, not the app=fake-worker pods other
|
||||||
|
# tests deploy in smg-test. Without this, the exact-count assertion
|
||||||
|
# in the test would race with concurrent test files.
|
||||||
|
args:
|
||||||
|
- "--service-discovery"
|
||||||
|
- "--selector"
|
||||||
|
- "app=fake-worker"
|
||||||
|
- "cross-ns-test=true"
|
||||||
|
- "--service-discovery-port"
|
||||||
|
- "8000"
|
||||||
|
- "--port"
|
||||||
|
- "30002"
|
||||||
|
- "--prometheus-port"
|
||||||
|
- "29002"
|
||||||
|
- "--disable-health-check"
|
||||||
|
- "--worker-startup-timeout-secs"
|
||||||
|
- "30"
|
||||||
|
- "--log-level"
|
||||||
|
- "debug"
|
||||||
|
ports:
|
||||||
|
- containerPort: 30002
|
||||||
|
name: http
|
||||||
|
- containerPort: 29002
|
||||||
|
name: metrics
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /liveness
|
||||||
|
port: 30002
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 3
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /liveness
|
||||||
|
port: 30002
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-cluster
|
||||||
|
namespace: smg-test
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector:
|
||||||
|
app: smg-gateway-cluster
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 30002
|
||||||
|
targetPort: 30002
|
||||||
|
- name: metrics
|
||||||
|
port: 29002
|
||||||
|
targetPort: 29002
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Two gateways with disjoint --selector values to verify per-model isolation.
|
||||||
|
# Both watch the smg-test namespace, both use the namespace-scoped RBAC from
|
||||||
|
# rbac.yaml, but each only matches workers carrying its own `model=...` label.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-llama
|
||||||
|
namespace: smg-test
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: smg-gateway-llama
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: smg-gateway-llama
|
||||||
|
spec:
|
||||||
|
serviceAccountName: smg-gateway
|
||||||
|
containers:
|
||||||
|
- name: gateway
|
||||||
|
image: smg-gateway:test
|
||||||
|
imagePullPolicy: Never
|
||||||
|
args:
|
||||||
|
- "--service-discovery"
|
||||||
|
- "--selector"
|
||||||
|
- "app=fake-worker"
|
||||||
|
- "model=llama"
|
||||||
|
- "--service-discovery-port"
|
||||||
|
- "8000"
|
||||||
|
- "--service-discovery-namespace"
|
||||||
|
- "smg-test"
|
||||||
|
- "--port"
|
||||||
|
- "30003"
|
||||||
|
- "--prometheus-port"
|
||||||
|
- "29003"
|
||||||
|
- "--disable-health-check"
|
||||||
|
- "--worker-startup-timeout-secs"
|
||||||
|
- "30"
|
||||||
|
- "--log-level"
|
||||||
|
- "debug"
|
||||||
|
ports:
|
||||||
|
- containerPort: 30003
|
||||||
|
name: http
|
||||||
|
- containerPort: 29003
|
||||||
|
name: metrics
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /liveness
|
||||||
|
port: 30003
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 3
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /liveness
|
||||||
|
port: 30003
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-llama
|
||||||
|
namespace: smg-test
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector:
|
||||||
|
app: smg-gateway-llama
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 30003
|
||||||
|
targetPort: 30003
|
||||||
|
- name: metrics
|
||||||
|
port: 29003
|
||||||
|
targetPort: 29003
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-qwen
|
||||||
|
namespace: smg-test
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: smg-gateway-qwen
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: smg-gateway-qwen
|
||||||
|
spec:
|
||||||
|
serviceAccountName: smg-gateway
|
||||||
|
containers:
|
||||||
|
- name: gateway
|
||||||
|
image: smg-gateway:test
|
||||||
|
imagePullPolicy: Never
|
||||||
|
args:
|
||||||
|
- "--service-discovery"
|
||||||
|
- "--selector"
|
||||||
|
- "app=fake-worker"
|
||||||
|
- "model=qwen"
|
||||||
|
- "--service-discovery-port"
|
||||||
|
- "8000"
|
||||||
|
- "--service-discovery-namespace"
|
||||||
|
- "smg-test"
|
||||||
|
- "--port"
|
||||||
|
- "30004"
|
||||||
|
- "--prometheus-port"
|
||||||
|
- "29004"
|
||||||
|
- "--disable-health-check"
|
||||||
|
- "--worker-startup-timeout-secs"
|
||||||
|
- "30"
|
||||||
|
- "--log-level"
|
||||||
|
- "debug"
|
||||||
|
ports:
|
||||||
|
- containerPort: 30004
|
||||||
|
name: http
|
||||||
|
- containerPort: 29004
|
||||||
|
name: metrics
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /liveness
|
||||||
|
port: 30004
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 3
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /liveness
|
||||||
|
port: 30004
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-qwen
|
||||||
|
namespace: smg-test
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector:
|
||||||
|
app: smg-gateway-qwen
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 30004
|
||||||
|
targetPort: 30004
|
||||||
|
- name: metrics
|
||||||
|
port: 29004
|
||||||
|
targetPort: 29004
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Dedicated gateway used by the gateway-restart test in test_lifecycle.py.
|
||||||
|
# Runs on its own ports (30005/29005) so killing this gateway's pod does
|
||||||
|
# not invalidate the session-scoped port-forward against the default
|
||||||
|
# smg-gateway used by test_reconciliation.py.
|
||||||
|
#
|
||||||
|
# This gateway's selector requires `app=fake-worker,lifecycle=restart`, so
|
||||||
|
# pods from test_reconciliation.py (which carry only `app=fake-worker`) are
|
||||||
|
# ignored here. The default `smg-gateway` does still match this test's
|
||||||
|
# `lifecycle=restart` workers (its selector is the broader subset), but the
|
||||||
|
# restart test never queries port 30000 — it only reads from this gateway,
|
||||||
|
# so worker counts stay deterministic.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-restart
|
||||||
|
namespace: smg-test
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: smg-gateway-restart
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: smg-gateway-restart
|
||||||
|
spec:
|
||||||
|
serviceAccountName: smg-gateway
|
||||||
|
containers:
|
||||||
|
- name: gateway
|
||||||
|
image: smg-gateway:test
|
||||||
|
imagePullPolicy: Never
|
||||||
|
args:
|
||||||
|
- "--service-discovery"
|
||||||
|
- "--selector"
|
||||||
|
- "app=fake-worker"
|
||||||
|
- "lifecycle=restart"
|
||||||
|
- "--service-discovery-port"
|
||||||
|
- "8000"
|
||||||
|
- "--service-discovery-namespace"
|
||||||
|
- "smg-test"
|
||||||
|
- "--port"
|
||||||
|
- "30005"
|
||||||
|
- "--prometheus-port"
|
||||||
|
- "29005"
|
||||||
|
- "--disable-health-check"
|
||||||
|
- "--worker-startup-timeout-secs"
|
||||||
|
- "30"
|
||||||
|
- "--log-level"
|
||||||
|
- "debug"
|
||||||
|
ports:
|
||||||
|
- containerPort: 30005
|
||||||
|
name: http
|
||||||
|
- containerPort: 29005
|
||||||
|
name: metrics
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /liveness
|
||||||
|
port: 30005
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 3
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /liveness
|
||||||
|
port: 30005
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-restart
|
||||||
|
namespace: smg-test
|
||||||
|
spec:
|
||||||
|
type: NodePort
|
||||||
|
selector:
|
||||||
|
app: smg-gateway-restart
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 30005
|
||||||
|
targetPort: 30005
|
||||||
|
- name: metrics
|
||||||
|
port: 29005
|
||||||
|
targetPort: 29005
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Cluster-wide RBAC for the multi-namespace discovery test.
|
||||||
|
# Distinct ServiceAccount/ClusterRole names so it does not collide with the
|
||||||
|
# namespace-scoped Role in rbac.yaml that the default gateway uses.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-cluster
|
||||||
|
namespace: smg-test
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-cluster
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: smg-gateway-cluster
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: smg-gateway-cluster
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: smg-gateway-cluster
|
||||||
|
namespace: smg-test
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""Cross-namespace service discovery integration test.
|
||||||
|
|
||||||
|
When the gateway is started without --service-discovery-namespace, the K8s
|
||||||
|
API client falls through to Api::all (sgl-model-gateway/src/service_discovery.rs:279),
|
||||||
|
watching pods in every namespace. That path requires a ClusterRole rather
|
||||||
|
than the namespace-scoped Role used by the default gateway.
|
||||||
|
|
||||||
|
Validates: a single gateway with cluster-wide RBAC discovers workers running
|
||||||
|
in two distinct namespaces, with each worker registered exactly once.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
cd e2e_test/k8s_integration
|
||||||
|
pytest test_cross_namespace.py -v -s
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import ( # pytest's rootdir adds the test dir to sys.path
|
||||||
|
FAKE_WORKER_SCRIPT,
|
||||||
|
KUBECTL_CONTEXT,
|
||||||
|
NAMESPACE,
|
||||||
|
_apply_from_stdin,
|
||||||
|
_cleanup_port_forward,
|
||||||
|
_get_workers,
|
||||||
|
_kubectl,
|
||||||
|
_kubectl_json,
|
||||||
|
_poll_until,
|
||||||
|
_port_forward_start,
|
||||||
|
_wait_for_deployment_ready,
|
||||||
|
_wait_for_pod_ready,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MANIFESTS_DIR = Path(__file__).parent / "manifests"
|
||||||
|
|
||||||
|
CLUSTER_GATEWAY_HTTP_PORT = 30002
|
||||||
|
EXTRA_NAMESPACE = "smg-test-extra"
|
||||||
|
|
||||||
|
|
||||||
|
def _deploy_worker_pod(name: str, namespace: str):
|
||||||
|
"""Deploy a fake-worker pod in the given namespace.
|
||||||
|
|
||||||
|
Assumes the `fake-worker-script` ConfigMap already exists in that namespace.
|
||||||
|
The `cross-ns-test=true` label is paired with the gateway's selector in
|
||||||
|
gateway-cluster-scoped.yaml so this test owns its worker fleet exclusively
|
||||||
|
and the exact-count assertion isn't racy across files.
|
||||||
|
"""
|
||||||
|
pod_manifest = {
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Pod",
|
||||||
|
"metadata": {
|
||||||
|
"name": name,
|
||||||
|
"namespace": namespace,
|
||||||
|
"labels": {"app": "fake-worker", "cross-ns-test": "true"},
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"name": "worker",
|
||||||
|
"image": "python:3.12-slim",
|
||||||
|
"imagePullPolicy": "IfNotPresent",
|
||||||
|
"command": ["python3", "/app/fake_worker.py"],
|
||||||
|
"ports": [{"containerPort": 8000}],
|
||||||
|
"readinessProbe": {
|
||||||
|
"httpGet": {"path": "/health", "port": 8000},
|
||||||
|
"initialDelaySeconds": 2,
|
||||||
|
"periodSeconds": 3,
|
||||||
|
},
|
||||||
|
"volumeMounts": [{"name": "app", "mountPath": "/app"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [{"name": "app", "configMap": {"name": "fake-worker-script"}}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
proc = subprocess.run(
|
||||||
|
["kubectl", "--context", KUBECTL_CONTEXT, "apply", "-f", "-"],
|
||||||
|
input=json.dumps(pod_manifest),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
# kubectl's actual error (admission webhook, missing configmap, schema
|
||||||
|
# validation) lives in stderr; surface it instead of bubbling up an
|
||||||
|
# opaque "non-zero exit status 1".
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to deploy pod {name} in namespace {namespace} "
|
||||||
|
f"(rc={proc.returncode}): stderr={proc.stderr.strip()!r}"
|
||||||
|
)
|
||||||
|
logger.info("Deployed worker %s in namespace %s", name, namespace)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pod_ip(name: str, namespace: str) -> str:
|
||||||
|
"""Return the current podIP for a pod (must be running)."""
|
||||||
|
pod = _kubectl_json("get", "pod", name, "-n", namespace)
|
||||||
|
ip = pod.get("status", {}).get("podIP")
|
||||||
|
if not ip:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Pod {namespace}/{name} has no podIP yet: {pod.get('status')}"
|
||||||
|
)
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_delete_pod(name: str, namespace: str):
|
||||||
|
try:
|
||||||
|
_kubectl(
|
||||||
|
"delete",
|
||||||
|
"pod",
|
||||||
|
name,
|
||||||
|
"-n",
|
||||||
|
namespace,
|
||||||
|
"--ignore-not-found",
|
||||||
|
"--force",
|
||||||
|
"--grace-period=0",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Cleanup failed for pod %s in ns %s: %s", name, namespace, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_namespace(name: str):
|
||||||
|
"""Create a namespace if it doesn't already exist (apply is idempotent)."""
|
||||||
|
manifest = {
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Namespace",
|
||||||
|
"metadata": {"name": name},
|
||||||
|
}
|
||||||
|
_apply_from_stdin(json.dumps(manifest))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_fake_worker_configmap(namespace: str):
|
||||||
|
"""Create the fake-worker-script ConfigMap in the given namespace.
|
||||||
|
|
||||||
|
Mirrors the configmap shipped to the default namespace by the deploy_base
|
||||||
|
fixture, so workers in other namespaces can boot the same script.
|
||||||
|
"""
|
||||||
|
cm_yaml = _kubectl(
|
||||||
|
"create",
|
||||||
|
"configmap",
|
||||||
|
"fake-worker-script",
|
||||||
|
f"--from-file=fake_worker.py={FAKE_WORKER_SCRIPT}",
|
||||||
|
"-n",
|
||||||
|
namespace,
|
||||||
|
"--dry-run=client",
|
||||||
|
"-o",
|
||||||
|
"yaml",
|
||||||
|
)
|
||||||
|
_apply_from_stdin(cm_yaml.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def cluster_scoped_gateway(deploy_base):
|
||||||
|
"""Deploy the cluster-scoped RBAC + gateway, plus a second namespace.
|
||||||
|
|
||||||
|
Cleanup runs in `finally:` so a port-forward failure does not leak
|
||||||
|
deployments / RBAC objects across test runs.
|
||||||
|
"""
|
||||||
|
rbac_manifest = MANIFESTS_DIR / "rbac-cluster-scoped.yaml"
|
||||||
|
gateway_manifest = MANIFESTS_DIR / "gateway-cluster-scoped.yaml"
|
||||||
|
|
||||||
|
_kubectl("apply", "-f", str(rbac_manifest))
|
||||||
|
_ensure_namespace(EXTRA_NAMESPACE)
|
||||||
|
_ensure_fake_worker_configmap(EXTRA_NAMESPACE)
|
||||||
|
_kubectl("apply", "-f", str(gateway_manifest))
|
||||||
|
|
||||||
|
pf: subprocess.Popen | None = None
|
||||||
|
try:
|
||||||
|
_wait_for_deployment_ready("smg-gateway-cluster")
|
||||||
|
pf = _port_forward_start(
|
||||||
|
NAMESPACE,
|
||||||
|
"smg-gateway-cluster",
|
||||||
|
CLUSTER_GATEWAY_HTTP_PORT,
|
||||||
|
CLUSTER_GATEWAY_HTTP_PORT,
|
||||||
|
)
|
||||||
|
yield f"http://127.0.0.1:{CLUSTER_GATEWAY_HTTP_PORT}"
|
||||||
|
finally:
|
||||||
|
if pf is not None:
|
||||||
|
_cleanup_port_forward("cluster_gateway", pf)
|
||||||
|
_kubectl(
|
||||||
|
"delete", "-f", str(gateway_manifest), "--ignore-not-found", check=False
|
||||||
|
)
|
||||||
|
_kubectl("delete", "-f", str(rbac_manifest), "--ignore-not-found", check=False)
|
||||||
|
# Drop the extra namespace last so any worker pods left behind go with
|
||||||
|
# it. The bounded --timeout prevents pytest from hanging at session
|
||||||
|
# end if a finalizer in the namespace is stuck (kindnet, kube-system
|
||||||
|
# controllers); the cleanup error gets logged but pytest continues.
|
||||||
|
_kubectl(
|
||||||
|
"delete",
|
||||||
|
"namespace",
|
||||||
|
EXTRA_NAMESPACE,
|
||||||
|
"--ignore-not-found",
|
||||||
|
"--wait=true",
|
||||||
|
"--timeout=60s",
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClusterWideDiscovery:
|
||||||
|
"""Verify that a gateway with ClusterRole + no namespace filter sees pods
|
||||||
|
in every namespace."""
|
||||||
|
|
||||||
|
def test_workers_in_two_namespaces_are_both_discovered(
|
||||||
|
self, cluster_scoped_gateway
|
||||||
|
):
|
||||||
|
gateway_url = cluster_scoped_gateway
|
||||||
|
worker_a = "cross-ns-worker-a"
|
||||||
|
worker_b = "cross-ns-worker-b"
|
||||||
|
|
||||||
|
try:
|
||||||
|
_deploy_worker_pod(worker_a, NAMESPACE)
|
||||||
|
_deploy_worker_pod(worker_b, EXTRA_NAMESPACE)
|
||||||
|
_wait_for_pod_ready(worker_a, namespace=NAMESPACE)
|
||||||
|
_wait_for_pod_ready(worker_b, namespace=EXTRA_NAMESPACE)
|
||||||
|
|
||||||
|
ip_a = _get_pod_ip(worker_a, NAMESPACE)
|
||||||
|
ip_b = _get_pod_ip(worker_b, EXTRA_NAMESPACE)
|
||||||
|
logger.info(
|
||||||
|
"worker_a (%s/%s) ip=%s, worker_b (%s/%s) ip=%s",
|
||||||
|
NAMESPACE,
|
||||||
|
worker_a,
|
||||||
|
ip_a,
|
||||||
|
EXTRA_NAMESPACE,
|
||||||
|
worker_b,
|
||||||
|
ip_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
_poll_until(
|
||||||
|
lambda: _get_workers(gateway_url)["total"] >= 2,
|
||||||
|
"both workers discovered across namespaces",
|
||||||
|
timeout=30,
|
||||||
|
interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
workers = _get_workers(gateway_url)
|
||||||
|
urls = sorted(w["url"] for w in workers.get("workers", []))
|
||||||
|
logger.info("Discovered worker URLs: %s", urls)
|
||||||
|
|
||||||
|
# Each pod should appear once. With two pods deployed and two
|
||||||
|
# discovered, the URLs must be distinct (no duplicate registration
|
||||||
|
# from cluster-wide watcher firing twice).
|
||||||
|
assert len(urls) == len(set(urls)), f"Duplicate worker URLs: {urls}"
|
||||||
|
assert workers["total"] == 2, (
|
||||||
|
f"Expected exactly 2 workers across namespaces, "
|
||||||
|
f"got {workers['total']}: {urls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-pod IP membership: proves the gateway actually listed both
|
||||||
|
# namespaces, not "two pods from the same namespace by accident".
|
||||||
|
# A regression that quietly hardcoded a namespace filter would
|
||||||
|
# still produce total=2 if labels happened to match elsewhere,
|
||||||
|
# but only one of these IPs would surface.
|
||||||
|
assert any(
|
||||||
|
ip_a in u for u in urls
|
||||||
|
), f"worker_a IP {ip_a} (ns {NAMESPACE}) not in {urls}"
|
||||||
|
assert any(
|
||||||
|
ip_b in u for u in urls
|
||||||
|
), f"worker_b IP {ip_b} (ns {EXTRA_NAMESPACE}) not in {urls}"
|
||||||
|
finally:
|
||||||
|
_safe_delete_pod(worker_a, NAMESPACE)
|
||||||
|
_safe_delete_pod(worker_b, EXTRA_NAMESPACE)
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
"""Worker lifecycle integration tests.
|
||||||
|
|
||||||
|
Covers three scenarios that the existing reconciliation/PD tests don't:
|
||||||
|
|
||||||
|
1. Gateway pod restart with persistent workers — verifies the K8s watcher
|
||||||
|
re-discovers existing pods after the gateway restarts, with no duplicate
|
||||||
|
registrations.
|
||||||
|
2. Pod IP change (same pod name, new IP) — verifies the gateway's worker
|
||||||
|
registry tracks the new IP after a pod is force-deleted and recreated,
|
||||||
|
not the stale one.
|
||||||
|
3. Graceful drain — verifies the gateway deregisters a worker as soon as
|
||||||
|
K8s sets `metadata.deletionTimestamp` (handled by handle_pod_deletion in
|
||||||
|
sgl-model-gateway/src/service_discovery.rs:533), instead of waiting for
|
||||||
|
the pod to fully terminate. This is what keeps the registry fresh during
|
||||||
|
long terminationGracePeriodSeconds windows / preStop hooks.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
cd e2e_test/k8s_integration
|
||||||
|
pytest test_lifecycle.py -v -s
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import ( # pytest's rootdir adds the test dir to sys.path
|
||||||
|
KUBECTL_CONTEXT,
|
||||||
|
NAMESPACE,
|
||||||
|
RECONCILIATION_WAIT_SECS,
|
||||||
|
_cleanup_port_forward,
|
||||||
|
_get_worker_count,
|
||||||
|
_get_workers,
|
||||||
|
_kubectl,
|
||||||
|
_kubectl_json,
|
||||||
|
_poll_until,
|
||||||
|
_port_forward_start,
|
||||||
|
_wait_for_deployment_ready,
|
||||||
|
_wait_for_pod_ready,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MANIFESTS_DIR = Path(__file__).parent / "manifests"
|
||||||
|
|
||||||
|
RESTART_GATEWAY_HTTP_PORT = 30005
|
||||||
|
|
||||||
|
|
||||||
|
def _deploy_worker_pod(
|
||||||
|
name: str,
|
||||||
|
extra_labels: dict[str, str] | None = None,
|
||||||
|
grace_period_secs: int | None = None,
|
||||||
|
prestop_sleep_secs: int | None = None,
|
||||||
|
):
|
||||||
|
"""Deploy a fake-worker pod with optional extra labels and grace period.
|
||||||
|
|
||||||
|
`prestop_sleep_secs` adds an exec preStop hook (`sleep N`) so the pod
|
||||||
|
stays in the Terminating state for at least N seconds after a graceful
|
||||||
|
delete — long enough for the test to observe the watcher's
|
||||||
|
deletionTimestamp event firing before SIGKILL.
|
||||||
|
"""
|
||||||
|
labels = {"app": "fake-worker"}
|
||||||
|
if extra_labels:
|
||||||
|
labels.update(extra_labels)
|
||||||
|
|
||||||
|
container: dict = {
|
||||||
|
"name": "worker",
|
||||||
|
"image": "python:3.12-slim",
|
||||||
|
"imagePullPolicy": "IfNotPresent",
|
||||||
|
"command": ["python3", "/app/fake_worker.py"],
|
||||||
|
"ports": [{"containerPort": 8000}],
|
||||||
|
"readinessProbe": {
|
||||||
|
"httpGet": {"path": "/health", "port": 8000},
|
||||||
|
"initialDelaySeconds": 2,
|
||||||
|
"periodSeconds": 3,
|
||||||
|
},
|
||||||
|
"volumeMounts": [{"name": "app", "mountPath": "/app"}],
|
||||||
|
}
|
||||||
|
if prestop_sleep_secs is not None:
|
||||||
|
container["lifecycle"] = {
|
||||||
|
"preStop": {"exec": {"command": ["sleep", str(prestop_sleep_secs)]}}
|
||||||
|
}
|
||||||
|
|
||||||
|
spec: dict = {
|
||||||
|
"containers": [container],
|
||||||
|
"volumes": [{"name": "app", "configMap": {"name": "fake-worker-script"}}],
|
||||||
|
}
|
||||||
|
if grace_period_secs is not None:
|
||||||
|
spec["terminationGracePeriodSeconds"] = grace_period_secs
|
||||||
|
|
||||||
|
pod_manifest = {
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Pod",
|
||||||
|
"metadata": {
|
||||||
|
"name": name,
|
||||||
|
"namespace": NAMESPACE,
|
||||||
|
"labels": labels,
|
||||||
|
},
|
||||||
|
"spec": spec,
|
||||||
|
}
|
||||||
|
proc = subprocess.run(
|
||||||
|
["kubectl", "--context", KUBECTL_CONTEXT, "apply", "-f", "-"],
|
||||||
|
input=json.dumps(pod_manifest),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
# Surface kubectl's actual error (webhook denial, schema, etc.)
|
||||||
|
# instead of an opaque "non-zero exit status 1" CalledProcessError.
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to deploy pod {name} (rc={proc.returncode}): "
|
||||||
|
f"stderr={proc.stderr.strip()!r}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Deployed pod %s (labels=%s, grace=%s, prestop=%s)",
|
||||||
|
name,
|
||||||
|
labels,
|
||||||
|
grace_period_secs,
|
||||||
|
prestop_sleep_secs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_force_delete(name: str):
|
||||||
|
"""Force-delete a pod, swallowing errors (used in cleanup)."""
|
||||||
|
try:
|
||||||
|
_kubectl(
|
||||||
|
"delete",
|
||||||
|
"pod",
|
||||||
|
name,
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"--ignore-not-found",
|
||||||
|
"--force",
|
||||||
|
"--grace-period=0",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Cleanup failed for pod %s: %s", name, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_pod_gone(name: str, timeout: int = 60):
|
||||||
|
"""Wait until a pod no longer exists.
|
||||||
|
|
||||||
|
Uses `kubectl get -o name --ignore-not-found`: empty stdout means the pod
|
||||||
|
is gone (no need to substring-match "NotFound" against stderr, which is
|
||||||
|
locale- and version-fragile). Any non-zero rc is a real cluster error
|
||||||
|
(apiserver unreachable, RBAC drift) and propagates immediately via
|
||||||
|
`_poll_until` (it only retries transient network errors, not RuntimeError).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _gone() -> bool:
|
||||||
|
result = _kubectl(
|
||||||
|
"get",
|
||||||
|
"pod",
|
||||||
|
name,
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"-o",
|
||||||
|
"name",
|
||||||
|
"--ignore-not-found",
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"kubectl get pod {name} failed unexpectedly "
|
||||||
|
f"(rc={result.returncode}): {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return not result.stdout.strip()
|
||||||
|
|
||||||
|
_poll_until(_gone, f"pod {name} gone", timeout=timeout, interval=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pod_ip(name: str) -> str:
|
||||||
|
"""Return the current podIP for a pod (must be running)."""
|
||||||
|
pod = _kubectl_json("get", "pod", name, "-n", NAMESPACE)
|
||||||
|
ip = pod.get("status", {}).get("podIP")
|
||||||
|
if not ip:
|
||||||
|
raise RuntimeError(f"Pod {name} has no podIP yet: {pod.get('status')}")
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def restart_gateway(deploy_base):
|
||||||
|
"""Deploy a dedicated gateway for the restart test on its own ports.
|
||||||
|
|
||||||
|
Function-scoped so each invocation starts from a clean Deployment — that
|
||||||
|
keeps the per-test pod-restart count predictable. The selector is
|
||||||
|
`app=fake-worker,lifecycle=restart`, distinct from the default gateway,
|
||||||
|
so the test owns the worker fleet exclusively.
|
||||||
|
|
||||||
|
Yields a single-element list `[pf]` so the test can swap in a fresh
|
||||||
|
port-forward after killing the gateway pod (the original `pf` exits with
|
||||||
|
the pod). Fixture teardown cleans up whichever handle is current.
|
||||||
|
"""
|
||||||
|
manifest = MANIFESTS_DIR / "gateway-restart.yaml"
|
||||||
|
_kubectl("apply", "-f", str(manifest))
|
||||||
|
pf_holder: list[subprocess.Popen | None] = [None]
|
||||||
|
try:
|
||||||
|
_wait_for_deployment_ready("smg-gateway-restart")
|
||||||
|
pf_holder[0] = _port_forward_start(
|
||||||
|
NAMESPACE,
|
||||||
|
"smg-gateway-restart",
|
||||||
|
RESTART_GATEWAY_HTTP_PORT,
|
||||||
|
RESTART_GATEWAY_HTTP_PORT,
|
||||||
|
)
|
||||||
|
yield f"http://127.0.0.1:{RESTART_GATEWAY_HTTP_PORT}", pf_holder
|
||||||
|
finally:
|
||||||
|
if pf_holder[0] is not None:
|
||||||
|
_cleanup_port_forward("restart_gateway", pf_holder[0])
|
||||||
|
_kubectl("delete", "-f", str(manifest), "--ignore-not-found", check=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGatewayRestart:
|
||||||
|
"""Killing the gateway pod must not lose worker state — the new pod
|
||||||
|
re-lists existing workers via the K8s watcher and registers them once."""
|
||||||
|
|
||||||
|
def test_workers_re_discovered_without_duplicates_after_restart(
|
||||||
|
self, restart_gateway
|
||||||
|
):
|
||||||
|
gateway_url, pf_holder = restart_gateway
|
||||||
|
worker_names = ["restart-worker-a", "restart-worker-b", "restart-worker-c"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for name in worker_names:
|
||||||
|
_deploy_worker_pod(name, extra_labels={"lifecycle": "restart"})
|
||||||
|
for name in worker_names:
|
||||||
|
_wait_for_pod_ready(name)
|
||||||
|
|
||||||
|
_poll_until(
|
||||||
|
lambda: _get_worker_count(gateway_url) >= len(worker_names),
|
||||||
|
f"all {len(worker_names)} workers discovered (pre-restart)",
|
||||||
|
timeout=30,
|
||||||
|
interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
urls_before = sorted(
|
||||||
|
w["url"] for w in _get_workers(gateway_url).get("workers", [])
|
||||||
|
)
|
||||||
|
logger.info("Workers before restart: %s", urls_before)
|
||||||
|
assert len(urls_before) == len(worker_names)
|
||||||
|
|
||||||
|
# Kill the gateway pod. The Deployment's ReplicaSet restarts it.
|
||||||
|
# Guard against an empty list — `_wait_for_deployment_ready` above
|
||||||
|
# gates on the rollout, but a controller race could in principle
|
||||||
|
# leave the selector momentarily empty, and an IndexError here
|
||||||
|
# would mask the real problem.
|
||||||
|
res = _kubectl_json(
|
||||||
|
"get",
|
||||||
|
"pod",
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"-l",
|
||||||
|
"app=smg-gateway-restart",
|
||||||
|
)
|
||||||
|
assert res.get(
|
||||||
|
"items"
|
||||||
|
), "No pods found for selector app=smg-gateway-restart"
|
||||||
|
old_pod = res["items"][0]["metadata"]["name"]
|
||||||
|
_kubectl(
|
||||||
|
"delete",
|
||||||
|
"pod",
|
||||||
|
old_pod,
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"--force",
|
||||||
|
"--grace-period=0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tear down the fixture's port-forward NOW, before the new pod is
|
||||||
|
# ready. Otherwise the stale kubectl process can briefly hold port
|
||||||
|
# 30005 while attempting to reconnect, and our fresh port-forward
|
||||||
|
# below races with it for "address already in use". After cleanup
|
||||||
|
# we clear the holder so the fixture teardown doesn't double-free.
|
||||||
|
if pf_holder[0] is not None:
|
||||||
|
_cleanup_port_forward("restart_gateway_pre_kill", pf_holder[0])
|
||||||
|
pf_holder[0] = None
|
||||||
|
|
||||||
|
_wait_for_deployment_ready("smg-gateway-restart")
|
||||||
|
|
||||||
|
# Open a fresh port-forward to the new gateway pod and stash it
|
||||||
|
# in the holder so the fixture cleans it up even if the assertion
|
||||||
|
# block raises before the explicit teardown.
|
||||||
|
pf_holder[0] = _port_forward_start(
|
||||||
|
NAMESPACE,
|
||||||
|
"smg-gateway-restart",
|
||||||
|
RESTART_GATEWAY_HTTP_PORT,
|
||||||
|
RESTART_GATEWAY_HTTP_PORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
_poll_until(
|
||||||
|
lambda: _get_worker_count(gateway_url) >= len(worker_names),
|
||||||
|
f"all {len(worker_names)} workers re-discovered (post-restart)",
|
||||||
|
timeout=60,
|
||||||
|
interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
urls_after = sorted(
|
||||||
|
w["url"] for w in _get_workers(gateway_url).get("workers", [])
|
||||||
|
)
|
||||||
|
logger.info("Workers after restart: %s", urls_after)
|
||||||
|
|
||||||
|
# No duplicates: each pod should appear exactly once.
|
||||||
|
assert len(urls_after) == len(
|
||||||
|
set(urls_after)
|
||||||
|
), f"Duplicate worker registrations after gateway restart: {urls_after}"
|
||||||
|
# Set equality: the same workers come back, neither dropped
|
||||||
|
# nor duplicated.
|
||||||
|
assert set(urls_after) == set(urls_before), (
|
||||||
|
f"Worker set diverged across gateway restart. "
|
||||||
|
f"before={urls_before}, after={urls_after}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Liveness probe of the new watcher: deploy one more worker AFTER
|
||||||
|
# the relist has converged. The relist can't pick this up — only
|
||||||
|
# an active watch loop can. A regression where the gateway does
|
||||||
|
# the initial list correctly but exits the watch loop afterward
|
||||||
|
# would silently pass the assertions above; this catches it.
|
||||||
|
extra_name = "restart-worker-d"
|
||||||
|
try:
|
||||||
|
_deploy_worker_pod(extra_name, extra_labels={"lifecycle": "restart"})
|
||||||
|
_wait_for_pod_ready(extra_name)
|
||||||
|
_poll_until(
|
||||||
|
lambda: _get_worker_count(gateway_url) >= len(worker_names) + 1,
|
||||||
|
f"post-restart watcher picked up {extra_name}",
|
||||||
|
timeout=30,
|
||||||
|
interval=2,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_safe_force_delete(extra_name)
|
||||||
|
finally:
|
||||||
|
for name in worker_names:
|
||||||
|
_safe_force_delete(name)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPodIpChange:
|
||||||
|
"""When a pod is deleted and recreated with the same name, the gateway's
|
||||||
|
worker registry must drop the old URL and pick up the new one. The test
|
||||||
|
skips on the rare CNI-IP-reuse case (since both URLs would be identical
|
||||||
|
and the assertions can't distinguish), so it does not exercise the
|
||||||
|
"old IP recycled by a different pod" scenario — covering that would need
|
||||||
|
a static-IP harness or controlled IP exhaustion, neither of which is
|
||||||
|
cheap on kind."""
|
||||||
|
|
||||||
|
def test_recreated_pod_yields_current_ip_in_worker_registry(
|
||||||
|
self, gateway_port_forward
|
||||||
|
):
|
||||||
|
gateway_url, _ = gateway_port_forward
|
||||||
|
pod_name = "ip-change-worker"
|
||||||
|
|
||||||
|
try:
|
||||||
|
_deploy_worker_pod(pod_name)
|
||||||
|
_wait_for_pod_ready(pod_name)
|
||||||
|
|
||||||
|
_poll_until(
|
||||||
|
lambda: _get_worker_count(gateway_url) >= 1,
|
||||||
|
"initial worker discovered",
|
||||||
|
timeout=30,
|
||||||
|
interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_before = _get_pod_ip(pod_name)
|
||||||
|
urls_before = {w["url"] for w in _get_workers(gateway_url)["workers"]}
|
||||||
|
assert any(
|
||||||
|
ip_before in url for url in urls_before
|
||||||
|
), f"Expected initial worker URL containing {ip_before}, got {urls_before}"
|
||||||
|
logger.info("Pod IP before: %s, urls: %s", ip_before, urls_before)
|
||||||
|
|
||||||
|
# Force-delete and wait until the registry no longer references
|
||||||
|
# this pod's IP. Asserting on "URL contains ip_before" rather than
|
||||||
|
# "total count == 0" keeps the test robust if a parallel/earlier
|
||||||
|
# test left an unrelated worker in the same gateway's registry.
|
||||||
|
_kubectl(
|
||||||
|
"delete",
|
||||||
|
"pod",
|
||||||
|
pod_name,
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"--force",
|
||||||
|
"--grace-period=0",
|
||||||
|
)
|
||||||
|
_wait_for_pod_gone(pod_name)
|
||||||
|
_poll_until(
|
||||||
|
lambda: not any(
|
||||||
|
ip_before in w["url"]
|
||||||
|
for w in _get_workers(gateway_url).get("workers", [])
|
||||||
|
),
|
||||||
|
f"stale worker for IP {ip_before} removed",
|
||||||
|
timeout=RECONCILIATION_WAIT_SECS,
|
||||||
|
interval=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recreate with same pod name. CNI typically assigns a new IP
|
||||||
|
# since the previous one isn't immediately recycled, but the
|
||||||
|
# assertion below works either way.
|
||||||
|
_deploy_worker_pod(pod_name)
|
||||||
|
_wait_for_pod_ready(pod_name)
|
||||||
|
|
||||||
|
ip_after = _get_pod_ip(pod_name)
|
||||||
|
# If the CNI happened to recycle the same IP, skip — both the
|
||||||
|
# "no URL contains ip_before" wait and the "URL contains ip_after"
|
||||||
|
# assertion describe the same string, so the test would silently
|
||||||
|
# pass without exercising the IP-change path it's meant to cover.
|
||||||
|
if ip_after == ip_before:
|
||||||
|
pytest.skip(
|
||||||
|
f"CNI reused pod IP {ip_before}; cannot validate IP-change "
|
||||||
|
f"path without two distinct IPs"
|
||||||
|
)
|
||||||
|
|
||||||
|
_poll_until(
|
||||||
|
lambda: any(
|
||||||
|
ip_after in w["url"]
|
||||||
|
for w in _get_workers(gateway_url).get("workers", [])
|
||||||
|
),
|
||||||
|
f"recreated worker (IP {ip_after}) discovered",
|
||||||
|
timeout=30,
|
||||||
|
interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
urls_after = {w["url"] for w in _get_workers(gateway_url)["workers"]}
|
||||||
|
logger.info("Pod IP after: %s, urls: %s", ip_after, urls_after)
|
||||||
|
|
||||||
|
# The registry must reflect the current pod's IP, and the old IP
|
||||||
|
# must not linger. Tested by URL membership rather than count to
|
||||||
|
# tolerate unrelated workers from other tests.
|
||||||
|
matching_after = [u for u in urls_after if ip_after in u]
|
||||||
|
assert len(matching_after) == 1, (
|
||||||
|
f"Expected exactly one worker URL containing current IP "
|
||||||
|
f"{ip_after}, got {matching_after} (all urls: {urls_after})"
|
||||||
|
)
|
||||||
|
assert not any(
|
||||||
|
ip_before in u for u in urls_after
|
||||||
|
), f"Stale URL with old IP {ip_before} still in registry: {urls_after}"
|
||||||
|
finally:
|
||||||
|
_safe_force_delete(pod_name)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGracefulDrain:
|
||||||
|
"""A graceful `kubectl delete pod` sets metadata.deletionTimestamp; the
|
||||||
|
watcher's `applied_objects()` stream emits the resulting MODIFIED event,
|
||||||
|
which the if-branch at service_discovery.rs:349 routes to
|
||||||
|
`handle_pod_deletion` (defined at service_discovery.rs:533). That removes
|
||||||
|
the worker immediately rather than waiting out the grace period or the
|
||||||
|
eventual SIGKILL → DELETED event (which `applied_objects()` filters out
|
||||||
|
anyway). This test pins that behavior so a future regression doesn't keep
|
||||||
|
a Terminating pod in the registry while kubelet is already running its
|
||||||
|
preStop hook."""
|
||||||
|
|
||||||
|
def test_deregistration_fires_during_grace_period(self, gateway_port_forward):
|
||||||
|
gateway_url, _ = gateway_port_forward
|
||||||
|
pod_name = "graceful-drain-worker"
|
||||||
|
# Grace period and preStop sleep need to be long enough that the
|
||||||
|
# watcher reliably observes the deletion *while the pod is still
|
||||||
|
# Terminating*. 60s grace + 40s sleep gives ~40s of overlap before
|
||||||
|
# SIGKILL — far more than the watcher's typical event latency
|
||||||
|
# (sub-second under normal conditions).
|
||||||
|
grace_secs = 60
|
||||||
|
prestop_sleep = 40
|
||||||
|
|
||||||
|
try:
|
||||||
|
_deploy_worker_pod(
|
||||||
|
pod_name,
|
||||||
|
grace_period_secs=grace_secs,
|
||||||
|
prestop_sleep_secs=prestop_sleep,
|
||||||
|
)
|
||||||
|
_wait_for_pod_ready(pod_name)
|
||||||
|
pod_ip = _get_pod_ip(pod_name)
|
||||||
|
_poll_until(
|
||||||
|
lambda: any(
|
||||||
|
pod_ip in w["url"]
|
||||||
|
for w in _get_workers(gateway_url).get("workers", [])
|
||||||
|
),
|
||||||
|
f"worker {pod_name} (ip {pod_ip}) registered before drain",
|
||||||
|
timeout=30,
|
||||||
|
interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Graceful delete: no --force, no --grace-period=0. K8s sets
|
||||||
|
# deletionTimestamp and starts the preStop hook + grace timer.
|
||||||
|
delete_started = time.time()
|
||||||
|
_kubectl(
|
||||||
|
"delete",
|
||||||
|
"pod",
|
||||||
|
pod_name,
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
f"--grace-period={grace_secs}",
|
||||||
|
"--wait=false",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assertion is URL-membership rather than total count: a stale
|
||||||
|
# worker leaked from a prior test would skew a count comparison
|
||||||
|
# without affecting whether *this* pod's IP got removed. The
|
||||||
|
# meaningful timing guarantee (`elapsed < grace_secs`) is below.
|
||||||
|
_poll_until(
|
||||||
|
lambda: not any(
|
||||||
|
pod_ip in w["url"]
|
||||||
|
for w in _get_workers(gateway_url).get("workers", [])
|
||||||
|
),
|
||||||
|
f"worker for ip {pod_ip} deregistered after graceful delete",
|
||||||
|
timeout=RECONCILIATION_WAIT_SECS,
|
||||||
|
interval=1,
|
||||||
|
)
|
||||||
|
elapsed = time.time() - delete_started
|
||||||
|
logger.info(
|
||||||
|
"Worker deregistered %.1fs after delete (grace_period=%ds, "
|
||||||
|
"prestop_sleep=%ds)",
|
||||||
|
elapsed,
|
||||||
|
grace_secs,
|
||||||
|
prestop_sleep,
|
||||||
|
)
|
||||||
|
assert elapsed < grace_secs, (
|
||||||
|
f"Deregistration took {elapsed:.1f}s — that's >= grace_period "
|
||||||
|
f"({grace_secs}s). The gateway should have acted on the "
|
||||||
|
f"watcher's deletionTimestamp event well before the grace "
|
||||||
|
f"period elapsed (and well before the post-grace SIGKILL "
|
||||||
|
f"would emit a Deleted event that applied_objects() filters)."
|
||||||
|
)
|
||||||
|
|
||||||
|
# The pod should still exist (Terminating) at this point: it
|
||||||
|
# has roughly grace_secs - elapsed seconds left. This guards
|
||||||
|
# against a lucky-pass where the test runs so slowly that the
|
||||||
|
# pod is gone by the time we check, which would no longer prove
|
||||||
|
# the deletionTimestamp path. Use `--ignore-not-found` so a
|
||||||
|
# genuinely-missing pod yields rc=0 with empty stdout; any other
|
||||||
|
# non-zero rc is a real cluster error (apiserver blip, RBAC
|
||||||
|
# drift) and must not masquerade as "pod is gone".
|
||||||
|
get_result = _kubectl(
|
||||||
|
"get",
|
||||||
|
"pod",
|
||||||
|
pod_name,
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"-o",
|
||||||
|
"name",
|
||||||
|
"--ignore-not-found",
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if get_result.returncode != 0:
|
||||||
|
pytest.fail(
|
||||||
|
f"kubectl get pod {pod_name} failed unexpectedly "
|
||||||
|
f"(rc={get_result.returncode}): {get_result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
still_exists = bool(get_result.stdout.strip())
|
||||||
|
assert still_exists, (
|
||||||
|
f"Pod {pod_name} is already gone — the test ran too slowly "
|
||||||
|
f"to distinguish the deletionTimestamp event from pod-gone "
|
||||||
|
f"reconciliation. Increase grace_period or prestop_sleep."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_safe_force_delete(pod_name)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Multi-model selector isolation integration test.
|
||||||
|
|
||||||
|
Two gateways watch the same namespace with disjoint --selector values
|
||||||
|
(model=llama vs model=qwen). Each must register only the workers carrying
|
||||||
|
its own label and ignore the other gateway's pool — proving that
|
||||||
|
PodInfo::should_include (sgl-model-gateway/src/service_discovery.rs:99)
|
||||||
|
honors the configured label selector when running multiple gateways
|
||||||
|
side-by-side (the typical multi-tenant deployment pattern).
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
cd e2e_test/k8s_integration
|
||||||
|
pytest test_multi_model.py -v -s
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import ( # pytest's rootdir adds the test dir to sys.path
|
||||||
|
KUBECTL_CONTEXT,
|
||||||
|
NAMESPACE,
|
||||||
|
_cleanup_port_forward,
|
||||||
|
_get_workers,
|
||||||
|
_kubectl,
|
||||||
|
_poll_until,
|
||||||
|
_port_forward_start,
|
||||||
|
_wait_for_deployment_ready,
|
||||||
|
_wait_for_pod_ready,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MANIFESTS_DIR = Path(__file__).parent / "manifests"
|
||||||
|
|
||||||
|
LLAMA_GATEWAY_HTTP_PORT = 30003
|
||||||
|
QWEN_GATEWAY_HTTP_PORT = 30004
|
||||||
|
|
||||||
|
|
||||||
|
def _deploy_model_worker(name: str, model: str):
|
||||||
|
"""Deploy a fake-worker with both `app=fake-worker` and `model=<...>` labels.
|
||||||
|
|
||||||
|
Including `app=fake-worker` matches the existing baseline label used by
|
||||||
|
the default gateway's selector — but only the *model-specific* gateway
|
||||||
|
has the second `model=...` constraint, so worker-to-gateway mapping is
|
||||||
|
determined by the model label alone.
|
||||||
|
"""
|
||||||
|
pod_manifest = {
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Pod",
|
||||||
|
"metadata": {
|
||||||
|
"name": name,
|
||||||
|
"namespace": NAMESPACE,
|
||||||
|
"labels": {"app": "fake-worker", "model": model},
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"name": "worker",
|
||||||
|
"image": "python:3.12-slim",
|
||||||
|
"imagePullPolicy": "IfNotPresent",
|
||||||
|
"command": ["python3", "/app/fake_worker.py"],
|
||||||
|
"ports": [{"containerPort": 8000}],
|
||||||
|
"readinessProbe": {
|
||||||
|
"httpGet": {"path": "/health", "port": 8000},
|
||||||
|
"initialDelaySeconds": 2,
|
||||||
|
"periodSeconds": 3,
|
||||||
|
},
|
||||||
|
"volumeMounts": [{"name": "app", "mountPath": "/app"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [{"name": "app", "configMap": {"name": "fake-worker-script"}}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
proc = subprocess.run(
|
||||||
|
["kubectl", "--context", KUBECTL_CONTEXT, "apply", "-f", "-"],
|
||||||
|
input=json.dumps(pod_manifest),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
# Surface kubectl's actual error (webhook denial, schema, missing
|
||||||
|
# configmap, etc.) instead of an opaque CalledProcessError.
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to deploy worker {name} (rc={proc.returncode}): "
|
||||||
|
f"stderr={proc.stderr.strip()!r}"
|
||||||
|
)
|
||||||
|
logger.info("Deployed worker %s with model=%s", name, model)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_force_delete(name: str):
|
||||||
|
try:
|
||||||
|
_kubectl(
|
||||||
|
"delete",
|
||||||
|
"pod",
|
||||||
|
name,
|
||||||
|
"-n",
|
||||||
|
NAMESPACE,
|
||||||
|
"--ignore-not-found",
|
||||||
|
"--force",
|
||||||
|
"--grace-period=0",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Cleanup failed for pod %s: %s", name, e)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def multimodel_gateways(deploy_base):
|
||||||
|
"""Deploy llama + qwen gateways and start port-forwards to both.
|
||||||
|
|
||||||
|
Module-scoped: every test in this file shares the deployment to keep the
|
||||||
|
suite fast (gateway startup + readiness is the slowest step). Cleanup
|
||||||
|
runs in `finally:` so a port-forward failure cannot leak Deployments.
|
||||||
|
"""
|
||||||
|
manifest = MANIFESTS_DIR / "gateway-multimodel.yaml"
|
||||||
|
_kubectl("apply", "-f", str(manifest))
|
||||||
|
|
||||||
|
pf_llama: subprocess.Popen | None = None
|
||||||
|
pf_qwen: subprocess.Popen | None = None
|
||||||
|
try:
|
||||||
|
_wait_for_deployment_ready("smg-gateway-llama")
|
||||||
|
_wait_for_deployment_ready("smg-gateway-qwen")
|
||||||
|
pf_llama = _port_forward_start(
|
||||||
|
NAMESPACE,
|
||||||
|
"smg-gateway-llama",
|
||||||
|
LLAMA_GATEWAY_HTTP_PORT,
|
||||||
|
LLAMA_GATEWAY_HTTP_PORT,
|
||||||
|
)
|
||||||
|
pf_qwen = _port_forward_start(
|
||||||
|
NAMESPACE,
|
||||||
|
"smg-gateway-qwen",
|
||||||
|
QWEN_GATEWAY_HTTP_PORT,
|
||||||
|
QWEN_GATEWAY_HTTP_PORT,
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
f"http://127.0.0.1:{LLAMA_GATEWAY_HTTP_PORT}",
|
||||||
|
f"http://127.0.0.1:{QWEN_GATEWAY_HTTP_PORT}",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if pf_llama is not None:
|
||||||
|
_cleanup_port_forward("llama_gateway", pf_llama)
|
||||||
|
if pf_qwen is not None:
|
||||||
|
_cleanup_port_forward("qwen_gateway", pf_qwen)
|
||||||
|
_kubectl("delete", "-f", str(manifest), "--ignore-not-found", check=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiModelSelectorIsolation:
|
||||||
|
"""Each gateway sees only the worker pool that matches its selector."""
|
||||||
|
|
||||||
|
def test_each_gateway_sees_only_its_model_pool(self, multimodel_gateways):
|
||||||
|
llama_url, qwen_url = multimodel_gateways
|
||||||
|
llama_workers = ["model-llama-a", "model-llama-b"]
|
||||||
|
qwen_workers = ["model-qwen-a", "model-qwen-b"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for name in llama_workers:
|
||||||
|
_deploy_model_worker(name, model="llama")
|
||||||
|
for name in qwen_workers:
|
||||||
|
_deploy_model_worker(name, model="qwen")
|
||||||
|
for name in llama_workers + qwen_workers:
|
||||||
|
_wait_for_pod_ready(name)
|
||||||
|
|
||||||
|
_poll_until(
|
||||||
|
lambda: _get_workers(llama_url)["total"] >= len(llama_workers),
|
||||||
|
f"llama gateway sees {len(llama_workers)} workers",
|
||||||
|
timeout=30,
|
||||||
|
interval=3,
|
||||||
|
)
|
||||||
|
_poll_until(
|
||||||
|
lambda: _get_workers(qwen_url)["total"] >= len(qwen_workers),
|
||||||
|
f"qwen gateway sees {len(qwen_workers)} workers",
|
||||||
|
timeout=30,
|
||||||
|
interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
llama_view = _get_workers(llama_url)
|
||||||
|
qwen_view = _get_workers(qwen_url)
|
||||||
|
llama_urls = sorted(w["url"] for w in llama_view.get("workers", []))
|
||||||
|
qwen_urls = sorted(w["url"] for w in qwen_view.get("workers", []))
|
||||||
|
logger.info("Llama gateway workers: %s", llama_urls)
|
||||||
|
logger.info("Qwen gateway workers: %s", qwen_urls)
|
||||||
|
|
||||||
|
assert llama_view["total"] == len(llama_workers), (
|
||||||
|
f"Llama gateway should see exactly {len(llama_workers)} workers, "
|
||||||
|
f"got {llama_view['total']}: {llama_urls}"
|
||||||
|
)
|
||||||
|
assert qwen_view["total"] == len(qwen_workers), (
|
||||||
|
f"Qwen gateway should see exactly {len(qwen_workers)} workers, "
|
||||||
|
f"got {qwen_view['total']}: {qwen_urls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No URL should appear in both views — that would mean a
|
||||||
|
# selector mismatch leaked a worker into the wrong gateway.
|
||||||
|
cross_talk = set(llama_urls) & set(qwen_urls)
|
||||||
|
assert (
|
||||||
|
not cross_talk
|
||||||
|
), f"Workers leaked across model selectors: {cross_talk}"
|
||||||
|
finally:
|
||||||
|
for name in llama_workers + qwen_workers:
|
||||||
|
_safe_force_delete(name)
|
||||||
Reference in New Issue
Block a user