1
0
mirror of https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git synced 2026-11-03 01:13:24 +00:00

Add DeepSeek Harness Web agents-as-a-service platform pattern (#2)

Port the upstream hermes-agents-as-a-service example to DSH Web:
numbered manifests (00-prereqs .. 50-gateway), a Python gateway
(signup, per-user tokens, wake-on-connect proxy, idle suspend),
run-test-kind.sh smoke test, and docs. Fix deploy/k8s/sandbox-template.yaml
(v1beta1, correct image, volumeClaimTemplates). Add a validate workflow
(manifest server dry-run + gateway unit tests).

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
This commit is contained in:
SREDevOps.org Agent
2026-10-21 09:45:05 -03:00
committed by GitHub
parent 0d66cbaca1
commit 98cb547ae5
16 changed files with 1515 additions and 184 deletions

View File

@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY gateway.py .
USER 10000
CMD ["python", "gateway.py"]

View File

@@ -0,0 +1,348 @@
# Minimal agents-as-a-service gateway for DeepSeek Harness Web (DSH).
#
# A compact, single-file control+data plane adapted from the upstream
# hermes-agents-as-a-service gateway (kubernetes-sigs/agent-sandbox). It turns
# the DSH sandbox resources into a usable multi-user service:
#
# POST /users {"user": "alice"} signup: create a SandboxClaim over the
# warm pool, wait Ready, mint a bearer
# token (only its SHA-256 is stored, as a
# claim annotation)
# GET /users/<user> derived state: Ready/Waking/Suspended/...
# (requires the user's bearer token)
# DELETE /users/<user> delete the claim (cascades sandbox + PVC;
# requires the user's bearer token)
# ANY /u/<user>/<path> per-user proxy. Requests to a suspended
# sandbox are HELD while it resumes
# (wake-on-connect); everything is proxied
# to the DSH Web UI on :3080.
#
# A background sweeper suspends sandboxes idle for IDLE_TIMEOUT seconds —
# suspend deletes only the pod; the PVC (and the user's state) survives.
#
# Differences from the Hermes original:
# - Single upstream on :3080 (no OpenAI-compatible :8642 / dashboard :9119
# split, and no platform API key to inject).
# - The original Host header and X-Forwarded-* are forwarded (the gateway's
# own bearer token and any ?token= are still stripped) so DSH generates
# reachable links from the request Host.
# - Requests streaming proxies SSE but does NOT handle WebSocket upgrades;
# a per-user path-based Ingress is the alternative for full WS support.
#
# Deliberately NOT production code: single replica, in-memory idle clock,
# no TLS.
import hashlib
import os
import re
import secrets
import threading
import time
import requests
from flask import Flask, Response, jsonify, request
from kubernetes import client, config
GROUP, VERSION = "extensions.agents.x-k8s.io", "v1beta1"
CORE_GROUP = "agents.x-k8s.io"
NAMESPACE = os.environ.get("NAMESPACE", "dsh-demo")
POOL = os.environ.get("POOL", "dsh-pool")
IDLE_TIMEOUT = int(os.environ.get("IDLE_TIMEOUT", "60"))
WAKE_TIMEOUT = int(os.environ.get("WAKE_TIMEOUT", "120"))
TOKEN_ANNOTATION = "aaas.example.com/token-sha256"
# RFC 1123 label: lowercase alphanumerics and '-', must start/end
# alphanumeric. The claim name is "dsh-<user>", so cap the user part at
# 63 - len("dsh-") to keep the claim a valid Kubernetes object name.
DNS1123_LABEL = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
MAX_USER_LEN = 63 - len("dsh-")
try:
config.load_incluster_config()
except config.ConfigException:
config.load_kube_config()
crd = client.CustomObjectsApi()
app = Flask(__name__)
# Cap proxied request bodies: Flask rejects anything larger with 413 before
# request.get_data() would buffer it in memory.
app.config["MAX_CONTENT_LENGTH"] = \
int(os.environ.get("MAX_BODY_MB", "32")) * 1024 * 1024
last_activity: dict[str, float] = {}
# Admitted proxy requests per user. While > 0 the sweeper must not suspend:
# a request being served (or still streaming) after IDLE_TIMEOUT is
# activity, not idleness. The sweeper checks-and-suspends under the same
# lock that admission increments under, so the pair is atomic.
in_flight: dict[str, int] = {}
flight_lock = threading.Lock()
def end_flight(user: str):
with flight_lock:
in_flight[user] = max(in_flight.get(user, 1) - 1, 0)
last_activity[user] = time.time()
def claim_name(user: str) -> str:
return f"dsh-{user}"
def get_claim(user: str):
try:
return crd.get_namespaced_custom_object(
GROUP, VERSION, NAMESPACE, "sandboxclaims", claim_name(user))
except client.ApiException as e:
if e.status == 404:
return None
raise
def get_sandbox(name: str):
return crd.get_namespaced_custom_object(
CORE_GROUP, VERSION, NAMESPACE, "sandboxes", name)
def sandbox_of(claim):
name = (claim.get("status") or {}).get("sandbox", {}).get("name")
if not name:
return None
try:
return get_sandbox(name)
except client.ApiException as e:
if e.status == 404: # stale claim status, e.g. mid-cascade-delete
return None
raise
def condition(obj, ctype: str) -> bool:
for c in (obj.get("status") or {}).get("conditions", []):
if c.get("type") == ctype:
return c.get("status") == "True"
return False
def derive_state(sandbox) -> str:
"""The state machine a platform shows users (mode x conditions)."""
if sandbox is None:
return "Provisioning"
mode = sandbox["spec"].get("operatingMode", "Running")
if mode == "Suspended":
return "Suspended" if condition(sandbox, "Suspended") else "Suspending"
return "Ready" if condition(sandbox, "Ready") else "Waking"
def set_operating_mode(sandbox_name: str, mode: str):
crd.patch_namespaced_custom_object(
CORE_GROUP, VERSION, NAMESPACE, "sandboxes", sandbox_name,
{"spec": {"operatingMode": mode}})
def wait_ready(user: str, timeout: int):
deadline = time.time() + timeout
while time.time() < deadline:
claim = get_claim(user)
sandbox = sandbox_of(claim) if claim else None
if sandbox is not None and derive_state(sandbox) == "Ready":
return sandbox
time.sleep(1)
return None
def authorized(claim) -> bool:
# Header-only on purpose: a ?token= query param would leak the bearer
# token into access logs, browser history and Referer headers.
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
want = (claim["metadata"].get("annotations") or {}).get(TOKEN_ANNOTATION, "")
got = hashlib.sha256(token.encode()).hexdigest()
return bool(token) and secrets.compare_digest(got, want)
@app.post("/users")
def create_user():
user = (request.get_json(silent=True) or {}).get("user", "")
if not isinstance(user, str) or not DNS1123_LABEL.fullmatch(user) \
or len(user) > MAX_USER_LEN:
return jsonify(error="body must be {'user': '<dns-1123 label>'}"), 400
if get_claim(user) is not None:
return jsonify(error="user exists"), 409
token = secrets.token_urlsafe(32)
# The claim IS the user record: warmPoolRef only (env/VCTs would be
# rejected by the template policies), token hash as an annotation.
try:
crd.create_namespaced_custom_object(
GROUP, VERSION, NAMESPACE, "sandboxclaims", {
"apiVersion": f"{GROUP}/{VERSION}",
"kind": "SandboxClaim",
"metadata": {
"name": claim_name(user),
"annotations": {
TOKEN_ANNOTATION: hashlib.sha256(token.encode()).hexdigest()},
},
"spec": {
"warmPoolRef": {"name": POOL},
"additionalPodMetadata": {
"labels": {"sandbox.users.io/dsh-user": user}},
},
})
except client.ApiException as e:
if e.status == 409: # lost a concurrent-signup race for this user
return jsonify(error="user exists"), 409
raise
sandbox = wait_ready(user, WAKE_TIMEOUT)
last_activity[user] = time.time()
return jsonify(
user=user,
token=token, # shown once; only the hash is stored
state=derive_state(sandbox),
sandbox=(sandbox or {}).get("metadata", {}).get("name"),
), 201
@app.get("/users/<user>")
def get_user(user):
claim = get_claim(user)
if claim is None:
return jsonify(error="not found"), 404
if not authorized(claim): # only the token holder may read their state
return jsonify(error="unauthorized"), 401
sandbox = sandbox_of(claim)
return jsonify(user=user, state=derive_state(sandbox),
sandbox=(sandbox or {}).get("metadata", {}).get("name"))
@app.delete("/users/<user>")
def delete_user(user):
claim = get_claim(user)
if claim is None:
return jsonify(error="not found"), 404
if not authorized(claim): # deletion cascades the PVC: token required
return jsonify(error="unauthorized"), 401
try:
crd.delete_namespaced_custom_object(
GROUP, VERSION, NAMESPACE, "sandboxclaims", claim_name(user))
except client.ApiException as e:
if e.status != 404: # already gone => idempotent success
raise
last_activity.pop(user, None)
return jsonify(user=user, note="claim deleted; sandbox and PVC cascade")
@app.route("/u/<user>/", defaults={"path": ""},
methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/u/<user>/<path:path>",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
def proxy(user, path):
claim = get_claim(user)
if claim is None:
return jsonify(error="unknown user"), 404
if not authorized(claim):
return jsonify(error="unauthorized"), 401
# Reserve activity the moment the request is admitted — BEFORE the
# sandbox is resolved or woken — so the sweeper can never suspend
# between admission and the upstream dial. Every early return releases
# the reservation; the streaming path hands it to relay(), which
# releases it when the response finishes (or the client disconnects).
with flight_lock:
in_flight[user] = in_flight.get(user, 0) + 1
last_activity[user] = time.time()
streaming = False
try:
sandbox = sandbox_of(claim)
if sandbox is None:
return jsonify(error="sandbox not provisioned yet"), 503
if derive_state(sandbox) != "Ready":
# Wake-on-connect: flip the mode (safe in ANY state, including
# mid-suspension) and HOLD the request until Ready.
set_operating_mode(sandbox["metadata"]["name"], "Running")
sandbox = wait_ready(user, WAKE_TIMEOUT)
if sandbox is None:
return jsonify(error="DSH is waking up, retry shortly"), \
503, {"Retry-After": "10"}
last_activity[user] = time.time()
fqdn = (sandbox.get("status") or {}).get("serviceFQDN")
# Forward the original Host + X-Forwarded-* so DSH generates
# reachable links; still never forward the gateway's bearer token or
# a stray ?token= upstream where the app (or its logs) would see it.
headers = {k: v for k, v in request.headers
if k.lower() != "authorization"}
headers.setdefault("X-Forwarded-For", request.remote_addr or "")
headers.setdefault("X-Forwarded-Proto", request.scheme)
headers.setdefault("X-Forwarded-Host", request.host)
params = [(k, v) for k, v in request.args.items(multi=True)
if k != "token"]
upstream = f"http://{fqdn}:3080/{path}"
# The pod can be Ready a beat before the server binds; retry the
# dial.
for attempt in range(5):
try:
r = requests.request(
request.method, upstream, params=params,
data=request.get_data(), headers=headers,
stream=True, timeout=300)
break
except requests.ConnectionError:
if attempt == 4:
return jsonify(error="upstream unavailable"), 502
time.sleep(1)
def relay():
try:
yield from r.iter_content(chunk_size=None)
finally: # runs on stream completion AND client disconnect
r.close() # release the upstream connection either way
end_flight(user)
streaming = True # relay() now owns the in-flight reservation
return Response(
relay(),
status=r.status_code,
headers=[(k, v) for k, v in r.headers.items()
if k.lower() not in ("transfer-encoding", "connection")])
finally:
if not streaming:
end_flight(user)
def idle_sweeper():
"""Suspend sandboxes idle for IDLE_TIMEOUT (pod deleted, PVC survives)."""
while True:
time.sleep(15)
try:
claims = crd.list_namespaced_custom_object(
GROUP, VERSION, NAMESPACE, "sandboxclaims")["items"]
for c in claims:
user = c["metadata"]["name"].removeprefix("dsh-")
try:
sandbox = sandbox_of(c)
except client.ApiException:
continue
if sandbox is None or derive_state(sandbox) != "Ready":
continue
# Check-and-suspend atomically with proxy admission (which
# increments in_flight under this same lock).
with flight_lock:
if in_flight.get(user, 0) > 0:
continue # requests in flight => the user is active
idle = time.time() - \
last_activity.setdefault(user, time.time())
if idle < IDLE_TIMEOUT:
continue
print(f"suspending {user} (idle {int(idle)}s)",
flush=True)
set_operating_mode(sandbox["metadata"]["name"],
"Suspended")
except Exception as e:
# A transient API error must not kill this daemon thread — that
# would silently stop idle suspension for good.
print(f"sweeper: transient error, retrying next cycle: {e}",
flush=True)
if __name__ == "__main__":
threading.Thread(target=idle_sweeper, daemon=True).start()
# threaded=True so one user's wake-on-connect (which can hold a request
# for up to WAKE_TIMEOUT) doesn't block everyone else. Still Werkzeug's
# dev server — a real deployment would sit behind gunicorn or similar.
app.run(host="0.0.0.0", port=8080, threaded=True)

View File

@@ -0,0 +1,3 @@
flask==3.1.*
requests==2.32.*
kubernetes==31.0.*

View File

@@ -0,0 +1,382 @@
"""
Unit tests for the dsh-agents-as-a-service gateway example.
gateway.py runs as the control+data plane in front of agent-sandbox; these
tests cover its security/parsing logic (bearer-token auth, DNS-1123 user
validation, the mode x conditions state machine) and the /users and
idle-sweeper decision logic, with the Kubernetes CustomObjectsApi mocked.
No kind cluster or container build is needed.
gateway.py isn't a valid module name to import as a package member from a
test file that also wants a fresh module per test (its top level calls
config.load_incluster_config()/load_kube_config(), both of which would blow
up or reach a real cluster), so each test loads its own copy by file path
via the `gw` fixture, mirroring examples/hpa-swp-scaling/test_create_claim.py.
"""
import hashlib
import importlib.util
import json
import os
import time
from types import SimpleNamespace
from unittest import mock
import pytest
from kubernetes import client as k8s_client
from kubernetes.config import ConfigException
GATEWAY_PATH = os.path.join(os.path.dirname(__file__), "gateway.py")
class _StopLoop(Exception):
"""Sentinel used to break idle_sweeper()'s `while True` after one pass."""
def _load_gateway(monkeypatch, mock_crd):
monkeypatch.setenv("NAMESPACE", "test-ns")
monkeypatch.setenv("POOL", "test-pool")
monkeypatch.setenv("IDLE_TIMEOUT", "60")
monkeypatch.setenv("WAKE_TIMEOUT", "120")
with mock.patch("kubernetes.config.load_incluster_config",
side_effect=ConfigException("not running in a cluster")), \
mock.patch("kubernetes.config.load_kube_config"), \
mock.patch("kubernetes.client.CustomObjectsApi", return_value=mock_crd):
spec = importlib.util.spec_from_file_location("dsh_gateway", GATEWAY_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def gw(monkeypatch):
"""A freshly loaded gateway module (own last_activity/in_flight state)
with its Kubernetes CustomObjectsApi mocked, plus a Flask test client."""
mock_crd = mock.MagicMock()
module = _load_gateway(monkeypatch, mock_crd)
module.app.testing = True # propagate unhandled exceptions instead of a bare 500
return SimpleNamespace(module=module, crd=mock_crd, client=module.app.test_client())
def crd_get_side_effect(claims=None, sandboxes=None):
"""Builds a get_namespaced_custom_object side_effect that serves
"sandboxclaims" from `claims` and "sandboxes" from `sandboxes` (both
{name: obj_or_None} maps), raising a 404 ApiException for anything
missing -- mirroring how get_claim()/get_sandbox() interpret 404."""
claims = claims or {}
sandboxes = sandboxes or {}
def _get(group, version, namespace, plural, name):
if plural == "sandboxclaims":
table = claims
elif plural == "sandboxes":
table = sandboxes
else:
raise AssertionError(f"unexpected plural {plural!r}")
obj = table.get(name)
if obj is None:
raise k8s_client.ApiException(status=404)
return obj
return _get
# --- condition() ---------------------------------------------------------
def test_condition_true_when_status_matches(gw):
obj = {"status": {"conditions": [{"type": "Ready", "status": "True"}]}}
assert gw.module.condition(obj, "Ready") is True
def test_condition_false_when_status_is_false(gw):
obj = {"status": {"conditions": [{"type": "Ready", "status": "False"}]}}
assert gw.module.condition(obj, "Ready") is False
def test_condition_false_when_type_absent(gw):
obj = {"status": {"conditions": [{"type": "Suspended", "status": "True"}]}}
assert gw.module.condition(obj, "Ready") is False
def test_condition_false_when_no_status(gw):
assert gw.module.condition({}, "Ready") is False
# --- derive_state() --------------------------------------------------------
def test_derive_state_provisioning_when_sandbox_missing(gw):
assert gw.module.derive_state(None) == "Provisioning"
def test_derive_state_ready(gw):
sandbox = {"spec": {}, "status": {"conditions": [{"type": "Ready", "status": "True"}]}}
assert gw.module.derive_state(sandbox) == "Ready"
def test_derive_state_waking_when_not_yet_ready(gw):
sandbox = {"spec": {"operatingMode": "Running"}, "status": {"conditions": []}}
assert gw.module.derive_state(sandbox) == "Waking"
def test_derive_state_suspended(gw):
sandbox = {"spec": {"operatingMode": "Suspended"},
"status": {"conditions": [{"type": "Suspended", "status": "True"}]}}
assert gw.module.derive_state(sandbox) == "Suspended"
def test_derive_state_suspending_while_mode_flipped_but_not_settled(gw):
sandbox = {"spec": {"operatingMode": "Suspended"}, "status": {"conditions": []}}
assert gw.module.derive_state(sandbox) == "Suspending"
# --- authorized() ----------------------------------------------------------
def _claim_for_token(module, token):
return {"metadata": {"annotations": {
module.TOKEN_ANNOTATION: hashlib.sha256(token.encode()).hexdigest()}}}
def test_authorized_accepts_correct_bearer_token(gw):
claim = _claim_for_token(gw.module, "s3cret")
with gw.module.app.test_request_context(headers={"Authorization": "Bearer s3cret"}):
assert gw.module.authorized(claim) is True
def test_authorized_rejects_wrong_token(gw):
claim = _claim_for_token(gw.module, "s3cret")
with gw.module.app.test_request_context(headers={"Authorization": "Bearer wrong"}):
assert gw.module.authorized(claim) is False
def test_authorized_rejects_missing_header(gw):
claim = _claim_for_token(gw.module, "s3cret")
with gw.module.app.test_request_context():
assert gw.module.authorized(claim) is False
def test_authorized_rejects_empty_bearer_token(gw):
claim = _claim_for_token(gw.module, "")
with gw.module.app.test_request_context(headers={"Authorization": "Bearer "}):
# bool(token) guards against an empty token matching an empty/unset
# annotation hash.
assert gw.module.authorized(claim) is False
# --- POST /users (create_user) ---------------------------------------------
@pytest.mark.parametrize("bad_user", ["Alice", "-alice", "alice-", "", "a_b", 123, None])
def test_create_user_rejects_invalid_dns1123_label(gw, bad_user):
response = gw.client.post("/users", json={"user": bad_user})
assert response.status_code == 400
gw.crd.create_namespaced_custom_object.assert_not_called()
def test_create_user_rejects_label_over_max_len(gw):
too_long = "a" * (gw.module.MAX_USER_LEN + 1)
response = gw.client.post("/users", json={"user": too_long})
assert response.status_code == 400
gw.crd.create_namespaced_custom_object.assert_not_called()
def test_create_user_conflict_when_claim_already_exists(gw):
gw.crd.get_namespaced_custom_object.side_effect = crd_get_side_effect(
claims={"dsh-alice": {"metadata": {"name": "dsh-alice"}}})
response = gw.client.post("/users", json={"user": "alice"})
assert response.status_code == 409
gw.crd.create_namespaced_custom_object.assert_not_called()
def test_create_user_conflict_on_concurrent_signup_race(gw):
gw.crd.get_namespaced_custom_object.side_effect = k8s_client.ApiException(status=404)
gw.crd.create_namespaced_custom_object.side_effect = k8s_client.ApiException(status=409)
response = gw.client.post("/users", json={"user": "alice"})
assert response.status_code == 409
def test_create_user_success_stores_only_token_hash(gw, monkeypatch):
gw.crd.get_namespaced_custom_object.side_effect = k8s_client.ApiException(status=404)
ready_sandbox = {
"metadata": {"name": "dsh-alice-sbx"},
"spec": {},
"status": {"conditions": [{"type": "Ready", "status": "True"}]},
}
# wait_ready() polls with time.sleep(1); stub it out rather than racing
# a real poll loop in a unit test.
monkeypatch.setattr(gw.module, "wait_ready", lambda user, timeout: ready_sandbox)
response = gw.client.post("/users", json={"user": "alice"})
assert response.status_code == 201
body = response.get_json()
assert body["user"] == "alice"
assert body["state"] == "Ready"
assert body["sandbox"] == "dsh-alice-sbx"
assert body["token"] # shown once in the response...
gw.crd.create_namespaced_custom_object.assert_called_once()
claim_body = gw.crd.create_namespaced_custom_object.call_args.args[4]
assert claim_body["metadata"]["name"] == "dsh-alice"
# ...but only its SHA-256 is ever sent to the API server.
want_hash = hashlib.sha256(body["token"].encode()).hexdigest()
assert claim_body["metadata"]["annotations"][gw.module.TOKEN_ANNOTATION] == want_hash
assert body["token"] not in json.dumps(claim_body)
assert claim_body["spec"]["warmPoolRef"]["name"] == "test-pool"
assert claim_body["spec"]["additionalPodMetadata"]["labels"]["sandbox.users.io/dsh-user"] == "alice"
# --- GET /users/<user> and DELETE /users/<user> -----------------------------
def test_get_user_not_found(gw):
gw.crd.get_namespaced_custom_object.side_effect = k8s_client.ApiException(status=404)
response = gw.client.get("/users/alice")
assert response.status_code == 404
def test_get_user_requires_valid_token(gw):
claim = _claim_for_token(gw.module, "s3cret")
gw.crd.get_namespaced_custom_object.side_effect = crd_get_side_effect(
claims={"dsh-alice": claim})
response = gw.client.get("/users/alice", headers={"Authorization": "Bearer wrong"})
assert response.status_code == 401
def test_get_user_returns_state_when_authorized(gw):
claim = _claim_for_token(gw.module, "s3cret")
sandbox = {"metadata": {"name": "dsh-alice-sbx"}, "spec": {},
"status": {"conditions": [{"type": "Ready", "status": "True"}]}}
claim["status"] = {"sandbox": {"name": "dsh-alice-sbx"}}
gw.crd.get_namespaced_custom_object.side_effect = crd_get_side_effect(
claims={"dsh-alice": claim}, sandboxes={"dsh-alice-sbx": sandbox})
response = gw.client.get("/users/alice", headers={"Authorization": "Bearer s3cret"})
assert response.status_code == 200
assert response.get_json()["state"] == "Ready"
def test_delete_user_not_found(gw):
gw.crd.get_namespaced_custom_object.side_effect = k8s_client.ApiException(status=404)
response = gw.client.delete("/users/alice")
assert response.status_code == 404
def test_delete_user_requires_valid_token(gw):
claim = _claim_for_token(gw.module, "s3cret")
gw.crd.get_namespaced_custom_object.side_effect = crd_get_side_effect(
claims={"dsh-alice": claim})
response = gw.client.delete("/users/alice", headers={"Authorization": "Bearer wrong"})
assert response.status_code == 401
gw.crd.delete_namespaced_custom_object.assert_not_called()
def test_delete_user_cascades_and_clears_activity(gw):
claim = _claim_for_token(gw.module, "s3cret")
gw.crd.get_namespaced_custom_object.side_effect = crd_get_side_effect(
claims={"dsh-alice": claim})
gw.module.last_activity["alice"] = time.time()
response = gw.client.delete("/users/alice", headers={"Authorization": "Bearer s3cret"})
assert response.status_code == 200
gw.crd.delete_namespaced_custom_object.assert_called_once()
assert "alice" not in gw.module.last_activity
def test_delete_user_already_gone_is_idempotent(gw):
claim = _claim_for_token(gw.module, "s3cret")
gw.crd.get_namespaced_custom_object.side_effect = crd_get_side_effect(
claims={"dsh-alice": claim})
gw.crd.delete_namespaced_custom_object.side_effect = k8s_client.ApiException(status=404)
response = gw.client.delete("/users/alice", headers={"Authorization": "Bearer s3cret"})
assert response.status_code == 200
def test_delete_user_unexpected_api_error_propagates(gw):
claim = _claim_for_token(gw.module, "s3cret")
gw.crd.get_namespaced_custom_object.side_effect = crd_get_side_effect(
claims={"dsh-alice": claim})
gw.crd.delete_namespaced_custom_object.side_effect = k8s_client.ApiException(status=500)
with pytest.raises(k8s_client.ApiException):
gw.client.delete("/users/alice", headers={"Authorization": "Bearer s3cret"})
# --- idle_sweeper() ----------------------------------------------------------
def _run_one_sweep(gw):
# time.sleep(15) runs before the try/except in idle_sweeper's loop body,
# so the first call must succeed (to let one pass execute) and only the
# second call breaks out.
with mock.patch("time.sleep", side_effect=[None, _StopLoop()]):
with pytest.raises(_StopLoop):
gw.module.idle_sweeper()
def test_idle_sweeper_suspends_idle_ready_sandbox(gw):
sandbox = {"metadata": {"name": "dsh-alice-sbx"}, "spec": {},
"status": {"conditions": [{"type": "Ready", "status": "True"}]}}
claim_item = {"metadata": {"name": "dsh-alice"},
"status": {"sandbox": {"name": "dsh-alice-sbx"}}}
gw.crd.list_namespaced_custom_object.return_value = {"items": [claim_item]}
gw.crd.get_namespaced_custom_object.return_value = sandbox
gw.module.last_activity["alice"] = time.time() - gw.module.IDLE_TIMEOUT - 10
_run_one_sweep(gw)
gw.crd.patch_namespaced_custom_object.assert_called_once()
args = gw.crd.patch_namespaced_custom_object.call_args.args
assert args[4] == "dsh-alice-sbx"
assert args[5] == {"spec": {"operatingMode": "Suspended"}}
def test_idle_sweeper_skips_user_with_in_flight_request(gw):
sandbox = {"metadata": {"name": "dsh-alice-sbx"}, "spec": {},
"status": {"conditions": [{"type": "Ready", "status": "True"}]}}
claim_item = {"metadata": {"name": "dsh-alice"},
"status": {"sandbox": {"name": "dsh-alice-sbx"}}}
gw.crd.list_namespaced_custom_object.return_value = {"items": [claim_item]}
gw.crd.get_namespaced_custom_object.return_value = sandbox
gw.module.last_activity["alice"] = time.time() - gw.module.IDLE_TIMEOUT - 10
gw.module.in_flight["alice"] = 1
_run_one_sweep(gw)
gw.crd.patch_namespaced_custom_object.assert_not_called()
def test_idle_sweeper_skips_recently_active_user(gw):
sandbox = {"metadata": {"name": "dsh-alice-sbx"}, "spec": {},
"status": {"conditions": [{"type": "Ready", "status": "True"}]}}
claim_item = {"metadata": {"name": "dsh-alice"},
"status": {"sandbox": {"name": "dsh-alice-sbx"}}}
gw.crd.list_namespaced_custom_object.return_value = {"items": [claim_item]}
gw.crd.get_namespaced_custom_object.return_value = sandbox
gw.module.last_activity["alice"] = time.time()
_run_one_sweep(gw)
gw.crd.patch_namespaced_custom_object.assert_not_called()
def test_idle_sweeper_skips_sandbox_not_ready(gw):
sandbox = {"metadata": {"name": "dsh-alice-sbx"}, "spec": {}, "status": {"conditions": []}}
claim_item = {"metadata": {"name": "dsh-alice"},
"status": {"sandbox": {"name": "dsh-alice-sbx"}}}
gw.crd.list_namespaced_custom_object.return_value = {"items": [claim_item]}
gw.crd.get_namespaced_custom_object.return_value = sandbox
gw.module.last_activity["alice"] = time.time() - gw.module.IDLE_TIMEOUT - 10
_run_one_sweep(gw)
gw.crd.patch_namespaced_custom_object.assert_not_called()