mirror of
https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git
synced 2026-11-03 01:13:24 +00:00
Make DSH agents-as-a-service browser-usable; fix probe crash-loop (#3)
* Rewrite DSH gateway for browser sessions, HTTP and WebSocket - Replace the Flask proxy with a Starlette ASGI app (uvicorn factory), keeping the control plane (claims, wake/suspend, idle sweeper). - Add HttpOnly gateway sessions and a server-side DSH launch-token handshake: read the token from pod logs, exchange it for a dsh-auth cookie, cache per (user, pod UID, Host), refresh once on 401. - Proxy HTTP/SSE and WebSocket (/api/remote.mux) under /u/<user>/, with header stripping/injection, Location rewriting and wake-on-connect. - Add a landing/signup/login page, health endpoint, pinned dependencies, a read-only-rootfs gateway image, 87 cluster-free tests and docs. * Fix DSH probe crash-loop and harden gateway manifests - Probe /favicon.svg instead of /: DSH gates / behind its launch-token cookie and returns 401, which made warm-pool pods crash-loop. - Pass DSH_TRUSTED_HOSTS through the entrypoint as --trusted-host args for public/ingress hosts; keep localhost trusted. - Deploy the published :gateway image, add pods/pods-log RBAC, /healthz probes, read-only rootfs with a /tmp emptyDir and non-root security. - Add optional Traefik Ingress and document the prerequisites. * Update CI, smoke tests, build tooling and docs - Run gateway pytest/ruff and build/smoke-test the image in CI. - Parameterize the DSH build version and pnpm version inputs. - Exercise cookie signup, HTML proxy, WebSocket auth, suspend/wake and delete in run-test-kind.sh. - Add Makefile/build.sh targets and document the architecture, handshake, probes, trusted hosts, troubleshooting and limitations. --------- Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
This commit is contained in:
committed by
GitHub
parent
c9be929212
commit
6301c21bea
299
docs/architecture.md
Normal file
299
docs/architecture.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# Architecture
|
||||
|
||||
This document describes the browser-usable **agents-as-a-service** platform in
|
||||
[`examples/dsh-agents-as-a-service/`](../examples/dsh-agents-as-a-service/): how the gateway,
|
||||
Kubernetes, and DSH Web fit together, and why the design looks the way it does.
|
||||
|
||||
Scope: one Kubernetes namespace (`dsh-demo`), one shared DSH Web image, one gateway deployment,
|
||||
and one DSH Web pod + PVC per user. The cluster is assumed to run
|
||||
[agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) v1.0.7 with the extensions
|
||||
(`SandboxTemplate`, `SandboxWarmPool`, `SandboxClaim`).
|
||||
|
||||
## Container view
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
browser["Browser"]
|
||||
|
||||
subgraph cluster["Kubernetes cluster - namespace dsh-demo"]
|
||||
gw["Gateway Deployment<br/>Starlette ASGI on uvicorn :8080<br/>landing page + auth + proxy + sweeper"]
|
||||
api["Kubernetes API Server"]
|
||||
ctrl["agent-sandbox controller"]
|
||||
pool["SandboxWarmPool dsh-pool<br/>2 pre-warmed spares"]
|
||||
claim["SandboxClaim dsh-USER<br/>one per user"]
|
||||
sb["Sandbox dsh-pool-xxxx<br/>spec.operatingMode"]
|
||||
svc["Headless Service<br/>status.serviceFQDN"]
|
||||
pod["DSH Web pod<br/>node process :3080"]
|
||||
pvc[("PVC dsh-home-SANDBOX<br/>/home/node/.dsh")]
|
||||
end
|
||||
|
||||
browser -->|"HTTP + WebSocket<br/>/u/USER/**"| gw
|
||||
gw -->|"claims, sandboxes, pods, pod logs"| api
|
||||
api --> ctrl
|
||||
ctrl -->|"adopt / suspend / resume"| claim
|
||||
claim --> sb
|
||||
pool -.->|"replenishes"| sb
|
||||
sb --> svc
|
||||
svc --> pod
|
||||
pod --> pvc
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| **Browser** | Holds only the gateway session cookie (`dsh_gw_session`). Talks exclusively to the gateway. |
|
||||
| **Gateway** | Signup/login, session auth, `SandboxClaim` lifecycle, wake-on-connect, idle sweeper, HTTP/SSE/WebSocket reverse proxy, DSH launch-token handshake. |
|
||||
| **Kubernetes API** | Source of truth for claims, sandboxes, and pods; also the log source for the DSH launch token. |
|
||||
| **agent-sandbox controller** | Adopts warm spares on claim, flips `operatingMode` (Running/Suspended), garbage-collects sandbox + PVC on claim delete. |
|
||||
| **DSH Web pod** | One `dsh web` process per user, state on the per-user PVC, listening on `3080`. |
|
||||
| **Warm pool** | Keeps `N` Ready sandboxes so signup is a label flip instead of a cold start. |
|
||||
|
||||
## Flows
|
||||
|
||||
### Signup (browser)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Browser
|
||||
participant G as Gateway
|
||||
participant K as Kubernetes API
|
||||
participant C as agent-sandbox controller
|
||||
|
||||
B->>G: POST /users (form: user=bob, Origin)
|
||||
G->>G: validate user name and body size
|
||||
G->>K: create SandboxClaim dsh-bob (warmPoolRef)
|
||||
K->>C: reconcile claim
|
||||
C-->>K: adopt spare, Ready, status.sandbox.name = dsh-pool-xxxx
|
||||
G->>K: read claim (status) + sandbox + pod
|
||||
G-->>B: 303 Location /u/bob/ + Set-Cookie dsh_gw_session
|
||||
Note over G: JSON clients instead get 201 (Ready),<br/>202 (accepted, still waking), or 409 (conflict)
|
||||
```
|
||||
|
||||
Form signup waits up to `WAKE_TIMEOUT` for the claim to become `Ready`, then redirects; the
|
||||
first proxied request also waits for `Ready` and performs the DSH handshake (below). A `409`
|
||||
means the user already has a claim, in which case `POST /login` (bearer token) or the existing
|
||||
cookie is the right path.
|
||||
|
||||
### First request / proxy (with handshake)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Browser
|
||||
participant G as Gateway
|
||||
participant K as Kubernetes API
|
||||
participant D as DSH Web pod
|
||||
|
||||
B->>G: GET /u/bob/ (Cookie dsh_gw_session)
|
||||
G->>G: authenticate cookie, user = bob
|
||||
G->>K: resolve claim -> sandbox -> pod
|
||||
alt sandbox Suspended
|
||||
G->>K: patch operatingMode=Running (wake)
|
||||
loop until Ready or WAKE_TIMEOUT
|
||||
G->>K: read sandbox Ready condition
|
||||
end
|
||||
end
|
||||
G->>K: read pod logs (tail)
|
||||
K-->>G: "dsh web: ...?token=..." line
|
||||
G->>D: GET /?token=... (browser Host, X-Forwarded-*)
|
||||
D-->>G: 303 + Set-Cookie dsh-auth-*
|
||||
G->>G: cache cookie for (user, pod UID, Host)
|
||||
G->>G: strip Cookie/Authorization/hop-by-hop headers
|
||||
G->>D: replay request with dsh-auth cookie
|
||||
D-->>G: 200 HTML / SSE stream
|
||||
G-->>B: 200 (upstream Set-Cookie dropped, Location rewritten)
|
||||
```
|
||||
|
||||
Handshake cache behavior: one entry per `(user, pod UID, Host)`; refreshed once when DSH answers
|
||||
`401`; dropped when the sandbox is suspended or deleted. The launch token and the DSH auth
|
||||
cookie never reach the browser.
|
||||
|
||||
### Wake-on-connect and idle suspend
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant S as Sweeper - lifespan task
|
||||
participant G as Gateway
|
||||
participant K as Kubernetes API
|
||||
actor B as Browser
|
||||
|
||||
S->>G: no proxied request for IDLE_TIMEOUT
|
||||
G->>K: patch sandbox operatingMode=Suspended
|
||||
K-->>G: pod deleted, PVC + Service retained
|
||||
Note over S,G: in-flight requests/WebSockets are tracked,<br/>so a busy turn is never suspended
|
||||
B->>G: GET /u/bob/ after a while
|
||||
G->>K: patch operatingMode=Running
|
||||
G->>K: wait for Ready (WAKE_TIMEOUT)
|
||||
G-->>B: request held, then served
|
||||
```
|
||||
|
||||
Suspend deletes only the pod. The PVC keeps conversations, sessions, and config, so resume
|
||||
reattaches exactly the same state.
|
||||
|
||||
### WebSocket
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor B as Browser
|
||||
participant G as Gateway
|
||||
participant D as DSH Web pod
|
||||
|
||||
B->>G: GET /u/bob/... Upgrade: websocket (Cookie dsh_gw_session)
|
||||
G->>G: authenticate + mark user in-flight
|
||||
alt cookie missing/invalid
|
||||
G-->>B: 101 upgrade, then close 4401
|
||||
else authenticated
|
||||
G->>D: websockets.connect(path, Host, Cookie dsh-auth)
|
||||
alt upstream handshake fails
|
||||
G-->>B: 101 upgrade, then close 1011
|
||||
else bridged
|
||||
G-->>B: 101 Switching Protocols
|
||||
loop frames
|
||||
B->>D: frames proxied through G
|
||||
D->>B: frames proxied through G
|
||||
end
|
||||
G->>G: clear in-flight on close
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Decisions and trade-offs
|
||||
|
||||
### ADR-1: Starlette ASGI instead of a sync Flask proxy
|
||||
|
||||
- **Decision:** implement the gateway as a Starlette app served by `uvicorn
|
||||
gateway:create_app --factory`.
|
||||
- **Why:** the browser needs WebSocket upgrades, streaming SSE responses, and concurrent
|
||||
long-poll wake requests. A requests-based Flask proxy cannot upgrade connections and blocks
|
||||
workers while a sandbox wakes.
|
||||
- **Cost:** async code paths and explicit bridging for the sync `kubernetes` client (offloaded
|
||||
to threads). Accepted: one small service, one framework.
|
||||
|
||||
### ADR-2: Path-based routing (`/u/{user}/`) instead of per-user subdomains
|
||||
|
||||
- **Decision:** serve each user under `https://gateway/u/{user}/`.
|
||||
- **Why:** one hostname, one TLS certificate, no wildcard DNS or per-user Ingress objects. DSH
|
||||
uses relative asset/API URLs and injects `<base href="./">`, so a path prefix works; the
|
||||
gateway rewrites absolute-path `Location` headers and redirects `/u/{user}` to
|
||||
`/u/{user}/`.
|
||||
- **Cost:** any upstream code that emits root-absolute URLs would need rewriting; cookie
|
||||
domains are shared (mitigated by stripping DSH `Set-Cookie`). If DSH ever changes its base
|
||||
handling, per-user subdomains are the fallback.
|
||||
|
||||
### ADR-3: Server-side launch-token handshake instead of exposing the token
|
||||
|
||||
- **Decision:** the gateway reads DSH's launch token from pod logs, performs the token
|
||||
handshake itself, caches DSH's auth cookie, and authenticates the browser with its own
|
||||
`HttpOnly` cookie.
|
||||
- **Why:** DSH has no token API and returns `401` on `/` without the launch-token cookie.
|
||||
Handing the token to the browser would put it in URLs, history, and logs, and would let users
|
||||
bypass gateway auth.
|
||||
- **Cost:** log reading permission and a brittle parser; see [Upstream feature
|
||||
request](#upstream-feature-request-remove-log-scraping). This is the least durable part of the
|
||||
design and the first thing to replace.
|
||||
|
||||
### ADR-4: In-memory state, single replica
|
||||
|
||||
- **Decision:** idle clocks, handshake cache, and in-flight tracking live in gateway memory;
|
||||
the Deployment runs one replica.
|
||||
- **Why:** the simplest thing that works, with no extra datastore for a demo/reference
|
||||
platform.
|
||||
- **Cost:** a restart forgets idle timers (sandboxes stay Running until their next request) and
|
||||
the handshake cache; two replicas would double-suspend and race. Scaling out requires moving
|
||||
this state to a shared store (Redis/Postgres) or leader election. Documented instead of
|
||||
hidden.
|
||||
|
||||
### ADR-5: Claims as the user record
|
||||
|
||||
- **Decision:** a user is a `SandboxClaim` named `dsh-<user>`; the bearer-token SHA-256 lives in
|
||||
a claim annotation.
|
||||
- **Why:** no database, no separate account service; deleting the claim is account deletion and
|
||||
cascades everything.
|
||||
- **Cost:** no password reset, no admin API, no audit log of its own; Kubernetes API load grows
|
||||
with users. Fine for a reference pattern, not for a product.
|
||||
|
||||
### ADR-6: Gateway cookie session instead of bearer tokens in the browser
|
||||
|
||||
- **Decision:** browsers get `dsh_gw_session=<user>:<token>`, `HttpOnly`, `SameSite=Lax`
|
||||
(`Secure` when enabled); API clients may still use `Authorization: Bearer`.
|
||||
- **Why:** tokens never appear in URLs or JS-readable storage, and `SameSite=Lax` plus a strict
|
||||
`Origin`/`Referer` check on cookie-authenticated mutations blocks basic CSRF.
|
||||
- **Cost:** cookie-based auth needs TLS and correct `COOKIE_SECURE`; there is no signed-cookie
|
||||
layer, so the gateway trusts the token hash comparison for validation.
|
||||
|
||||
## Security and RBAC
|
||||
|
||||
The gateway runs with a namespace-scoped ServiceAccount (`50-gateway.yaml`):
|
||||
|
||||
| API group | Resource | Verbs | Why |
|
||||
| --- | --- | --- | --- |
|
||||
| `extensions.agents.x-k8s.io` | `sandboxclaims` | `get`, `list`, `create`, `delete` | signup, state, account deletion |
|
||||
| `agents.x-k8s.io` | `sandboxes` | `get`, `list`, `patch` | resolve DNS/pod, wake/suspend |
|
||||
| `""` (core) | `pods` | `get`, `list` | resolve the pod behind a sandbox |
|
||||
| `""` (core) | `pods/log` | `get` | launch-token handshake |
|
||||
|
||||
Notes and limits:
|
||||
|
||||
- **Pod logs are sensitive.** They contain the DSH launch token and possibly model output. The
|
||||
permission is scoped to one namespace, but anyone who can read gateway memory or logs can
|
||||
impersonate users. Prefer the upstream token API when available.
|
||||
- **No cluster-wide access, no secret reads.** The gateway does not read `dsh-provider-keys`.
|
||||
- **Browser-facing hardening:** `HttpOnly`/`SameSite=Lax`/optional `Secure` cookies,
|
||||
timing-safe SHA-256 token comparison, strict same-origin check on cookie-auth mutations,
|
||||
request-body limit (`MAX_BODY_MB`), hop-by-hop header stripping, upstream `Set-Cookie`
|
||||
dropped, DSH `Location` headers rewritten under `/u/<user>/`.
|
||||
- **Host allow-list:** DSH rejects unknown `Host` headers. The gateway forwards the browser's
|
||||
`Host`, so every public hostname must be in `DSH_TRUSTED_HOSTS`; `localhost`/`127.0.0.1` are
|
||||
always trusted.
|
||||
- **TLS is external.** The gateway serves plain HTTP; terminate TLS at the Ingress and set
|
||||
`COOKIE_SECURE=true`. Without TLS, session cookies are replayable on the network.
|
||||
- **Tenant isolation** is pod + PVC separation; all tenants share one namespace, one DSH binary,
|
||||
and one `DEEPSEEK_API_KEY`. Add NetworkPolicies and per-tenant credentials before calling it
|
||||
multi-tenant-grade.
|
||||
- **No gateway rate limiting.** Signup can create claims until the cluster or warm pool is
|
||||
exhausted; put limits at the ingress for real deployments.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | Symptom | Behavior / mitigation |
|
||||
| --- | --- | --- |
|
||||
| DSH log line changed or rotated away | Proxy returns `503` (missing launch token) | Fix the parser or restart the pod so the token line is re-emitted |
|
||||
| Probe on `/` | Pod `CrashLoopBackOff`, `401` in probe logs | Use `/favicon.svg` for readiness/liveness (shipped templates do) |
|
||||
| Warm pool exhausted | JSON signup returns `202` (state `Waking`); form signup is slow | Increase `replicas` in the warm pool |
|
||||
| Gateway restart | Idle sandboxes stop suspending; handshake cache lost | Rebuilt lazily on next request; consider a shared store for production |
|
||||
| DSH auth cookie expired mid-session | Proxy `401` once, then recovers | Gateway refreshes the handshake once |
|
||||
| Upstream pod name changed after resume | Cached cookie keyed by old pod UID is useless | Cache is invalidated on suspend; next request re-handshakes |
|
||||
| Two gateway replicas | Double suspend/wake races | Keep `replicas: 1` until state is externalized |
|
||||
|
||||
## Upstream feature request: remove log scraping
|
||||
|
||||
DSH should expose the launch token as a first-class, machine-readable interface so platforms do
|
||||
not need `pods/log` access. Proposed shapes (any one suffices):
|
||||
|
||||
1. **Token file:** `dsh web --token-file /run/dsh/token` writes the current launch token (mode
|
||||
`0600`) and rewrites it on rotation.
|
||||
2. **Environment input:** accept `DSH_LAUNCH_TOKEN` (platform-generated) so the token never
|
||||
has to be discovered.
|
||||
3. **Loopback/trusted-host endpoint:** `GET /api/auth/launch-token` that only answers on the
|
||||
pod's loopback or a trusted host and returns the token plus expiry.
|
||||
4. **Token in a well-known file** under `DSH_HOME` with the same semantics as (1).
|
||||
|
||||
Why this matters: log scraping is brittle (rotation, restart, format changes), requires a broad
|
||||
`pods/log` permission that also exposes application output, and can leak tokens into log
|
||||
pipelines. Until an upstream API exists, the gateway's parser, `DSH_LOG_TAIL_LINES`, and the
|
||||
single-retry refresh are the supported workaround.
|
||||
|
||||
## Evolution path
|
||||
|
||||
1. Replace log scraping with the upstream token API (above), deleting the `pods/log` permission.
|
||||
2. Move idle clocks, in-flight tracking, and handshake cache to a shared store; then scale the
|
||||
gateway horizontally (sticky routing not required if state is shared).
|
||||
3. Add per-tenant API keys or an egress proxy so a single key is not shared by all sandboxes.
|
||||
4. Add NetworkPolicies (gateway → sandboxes only), TLS everywhere, rate limits, and audit
|
||||
logging at the ingress.
|
||||
5. If DSH grows absolute URLs or per-user cookies that cannot be rewritten, switch to
|
||||
per-user subdomains with wildcard DNS/TLS.
|
||||
Reference in New Issue
Block a user