Skip to content

Commit 7a878c9

Browse files
authored
feat: adopt agent-sandbox control plane for tenant workspaces (runc) (#6)
* feat: add agent-sandbox control-plane install + design doc (#365) Add scripts/setup-agent-sandbox.sh to install the kubernetes-sigs/agent-sandbox controller + CRDs (install commands per the official guide, pinned to v0.4.5 with an override). Add docs/agent-sandbox.md describing the sandbox-first adoption plan, the KEDA->native-hibernation decision, the background-work hibernation hazard + heartbeat mitigation, gVisor-via-SandboxTemplate, and the phased PR delivery. Additive only: does not yet touch the tenant Deployment or KEDA wiring. Refs #365 * feat: add runc SandboxTemplate for tenant workspaces (#365) OpenClaw-shaped runc (default) tier SandboxTemplate. The gVisor tier (PR #2) is the same template plus spec.podTemplate.spec.runtimeClassName: gvisor. Validated on a local kind cluster against agent-sandbox v0.4.5: - setup-agent-sandbox.sh installs controller + CRDs (controller pod Running) - this template passes kubectl apply --dry-run=server against the live CRD - a SandboxClaim referencing a template reconciles to a Running pod Per-tenant SA (Pod Identity) / EFS PVC / config+secret / env are layered at claim time during the tenant migration (next, requires live EKS to validate Bedrock + Pod Identity + EFS). Refs #365 * feat(sandbox): adopt Sandbox model for tenants (runc, always-on) + ADRs PR#1 of agent-sandbox adoption (#365). Tenant workloads move from Deployment to a per-tenant SandboxTemplate + SandboxClaim (v0.4.x v1alpha1, direct sandboxTemplateRef), runc tier, operatingMode Running (always-on). KEDA scale-to-zero removed; returns later via upstream KEP-968 (ADR-0006). gVisor is a one-value switch for PR#2. - helm: add sandboxtemplate.yaml + sandboxclaim.yaml; gate deployment/hpa/httpscaledobject/referencegrant on sandbox.enabled; httproute backend -> tenant Service in sandbox mode; values sandbox.* - applicationset: switch tenants to sandbox mode (scaleToZero off) - deploy-all.sh: install agent-sandbox controller + CRDs - docs/adr: ADR-0001..0006 (verified vs v0.4.5 CRD + KEP-968) Verified: helm template (both modes) + helm lint pass. Live deploy-verify pending. * fix(sandbox): don't create tenant Service in sandbox mode (controller owns headless svc) deploy-verify (hclo-mac/us-east-1) found the agent-sandbox controller creates its own headless Service named after the Sandbox; our same-named ClusterIP Service blocked adoption (ClusterIP immutable). Gate service.yaml off when sandbox.enabled; HTTPRoute backendRef resolves to the controller's Service. Verified live: SandboxClaim -> Template -> Sandbox -> Pod reconciles; controller creates headless Service; pod schedules, EFS PVC binds, all initContainers run, OpenClaw gateway starts. Pod only fails at runtime secret fetch (no Pod Identity assoc / gateway-token for the bare test tenant) -- expected; real tenants get these from the PostConfirmation Lambda. * ci: add sandbox-mode helm lint + pick up CI startup fix from main Merges the ci.yml startup fix (#7) and lints the chart in both legacy and sandbox modes so the new SandboxClaim/SandboxTemplate manifests are validated.
1 parent 61a6fd0 commit 7a878c9

22 files changed

Lines changed: 1067 additions & 7 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ jobs:
5454
echo "::endgroup::"
5555
5656
- name: Helm lint
57-
run: helm lint helm/charts/openclaw-platform/ --set ingress.enabled=true --set ingress.host=test.example.com --set tenant.name=test
57+
run: |
58+
# legacy (Deployment) mode
59+
helm lint helm/charts/openclaw-platform/ --set ingress.enabled=true --set ingress.host=test.example.com --set tenant.name=test
60+
# sandbox (SandboxClaim/SandboxTemplate) mode
61+
helm lint helm/charts/openclaw-platform/ --set ingress.enabled=true --set ingress.host=test.example.com --set tenant.name=test --set sandbox.enabled=true
5862
5963
- name: CDK unit tests
6064
working-directory: cdk
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# ADR-0001: Adopt the agent-sandbox model for tenant workspace isolation
2+
3+
- **Status:** Accepted
4+
- **Date:** 2026-06-18
5+
- **Deciders:** HC Lo (hclo)
6+
- **Tracking issue:** [#365](https://github.com/snese/sample-openclaw-multi-tenant-platform/issues/365)
7+
8+
## Context
9+
10+
Today each tenant workspace is a plain Kubernetes `Deployment`, isolated only
11+
at the namespace and `NetworkPolicy` level. Model-generated and user-driven
12+
code inside an OpenClaw workspace therefore runs against the full host-kernel
13+
syscall surface. For a multi-tenant platform that executes semi-trusted,
14+
LLM-driven workloads, this is the weakest part of the current design.
15+
16+
We also lack a first-class primitive for the thing each tenant actually is: a
17+
**long-running, stateful, singleton workspace with a stable identity and
18+
persistent storage**. Modelling that as a `Deployment` (stateless, replicated)
19+
is a poor fit and forces us to hand-stitch Service, PVC, and scaling behaviour.
20+
21+
We want:
22+
23+
1. A kernel-level isolation tier we can turn on per workload.
24+
2. A declarative, lifecycle-managed singleton primitive that matches the
25+
per-tenant persistent workspace model.
26+
3. To stay AWS-native and avoid adding a heavy, non-AWS supply-chain dependency.
27+
28+
## Decision
29+
30+
Adopt the [kubernetes-sigs/agent-sandbox](https://agent-sandbox.sigs.k8s.io/)
31+
model — the `Sandbox` CRD plus the `SandboxTemplate` / `SandboxClaim` /
32+
`SandboxWarmPool` extensions — as packaged by
33+
[awslabs/ai-on-eks](https://awslabs.github.io/ai-on-eks/docs/infra/agents/agent-sandbox).
34+
35+
The runtime is intentionally pluggable: this ADR commits only to the **model**.
36+
The kernel-isolation tier (gVisor) is delivered later as a `SandboxTemplate`
37+
`runtimeClassName` field, not as a separate layer (see ADR-0005).
38+
39+
## Options considered
40+
41+
### A. Status quo — namespace + NetworkPolicy only
42+
Rejected. Provides no kernel isolation; model-generated code keeps the full
43+
host syscall surface. Does not address the stateful-singleton modelling gap.
44+
45+
### B. NVIDIA OpenShell / NemoClaw
46+
Rejected. It is alpha and host-centric (a single-machine, always-on agent
47+
hardening CLI), and would have to be adapted into a sidecar/DaemonSet to fit a
48+
multi-tenant cluster. Its headline value — inference-layer credential injection
49+
so the agent never sees the API key — is **already provided** in this sample by
50+
Amazon Bedrock + EKS Pod Identity (zero API keys), so the marginal benefit here
51+
is effectively zero. Adopting it would also dilute the sample's "all
52+
AWS-native" positioning and add NVIDIA supply-chain and support risk.
53+
54+
### C. kubernetes-sigs agent-sandbox (chosen)
55+
Kubernetes-native CRDs purpose-built for isolated, stateful, singleton
56+
workloads in multi-tenant clusters. Integrates cleanly with the existing stack
57+
(Karpenter, ArgoCD ApplicationSet, Pod Identity/IRSA, NetworkPolicy, ResourceQuota).
58+
gVisor and Kata are selectable per template rather than bespoke runtime layers.
59+
Maintained by a Kubernetes SIG and packaged for EKS by awslabs/ai-on-eks.
60+
61+
## Consequences
62+
63+
**Positive**
64+
- A declarative "singleton pod with stable identity + persistent storage"
65+
primitive that matches the per-tenant workspace model directly.
66+
- Kernel isolation (gVisor) becomes a one-field change on a template (ADR-0005).
67+
- Stays AWS-native + OSS SIG; no NVIDIA wrapper in the supply chain.
68+
69+
**Negative / costs**
70+
- New control-plane dependency: the agent-sandbox controller + CRDs must be
71+
installed and operated (install script already added in PR #365).
72+
- The API is still evolving; we must pin a version and track drift
73+
(see ADR-0002).
74+
- The routing and scale-to-zero model must change, because a `Sandbox` is a
75+
singleton with no `/scale` subresource and the wake/hibernation path differs
76+
from the current KEDA HTTP add-on (see ADR-0004).
77+
- Per-tenant identity (ServiceAccount), secrets, and storage must be expressed
78+
within the template/claim model rather than a hand-written Deployment
79+
(see ADR-0003).
80+
81+
## References
82+
83+
- [Agent Sandbox documentation](https://agent-sandbox.sigs.k8s.io/docs/)
84+
- [awslabs/ai-on-eks — Agent Sandbox on EKS](https://awslabs.github.io/ai-on-eks/docs/infra/agents/agent-sandbox)
85+
- [docs/agent-sandbox.md](../agent-sandbox.md) — original design note (superseded in part by this ADR set)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# ADR-0002: Pin agent-sandbox v0.4.5 (`v1alpha1`) and use the direct `sandboxTemplateRef` chain
2+
3+
- **Status:** Proposed
4+
- **Date:** 2026-06-18
5+
- **Deciders:** HC Lo (hclo)
6+
- **Depends on:** [ADR-0001](0001-adopt-agent-sandbox-model.md)
7+
8+
## Context
9+
10+
The agent-sandbox API is still evolving and the resource shape changed between
11+
releases. The install script (`scripts/setup-agent-sandbox.sh`) pins
12+
`AGENT_SANDBOX_VERSION=v0.4.5`, and the runc `SandboxTemplate` was validated
13+
against CRD `v0.4.5`.
14+
15+
We verified the actual v0.4.5 CRD by pulling
16+
`releases/download/v0.4.5/extensions.yaml` directly. Findings:
17+
18+
- v0.4.5 serves **`extensions.agents.x-k8s.io/v1alpha1`**.
19+
- `SandboxClaim.spec` **requires `sandboxTemplateRef`** (a `{name}` reference
20+
**directly** to a `SandboxTemplate`). It also has an optional `warmpool`
21+
string (default `"default"`), `lifecycle`, `env`, and `additionalPodMetadata`.
22+
23+
The **current upstream docs** describe a later API (`v1beta1`) in which
24+
`SandboxClaim.spec` drops `sandboxTemplateRef` and instead requires
25+
`warmPoolRef` pointing at a `SandboxWarmPool`. That is a different chain
26+
(`SandboxClaim → SandboxWarmPool → SandboxTemplate`) and would not validate
27+
against the v0.4.5 CRD we install.
28+
29+
> An earlier draft of this design incorrectly assumed the `v1beta1`
30+
> (`warmPoolRef`) shape was authoritative. It is not, for the version we pin.
31+
> This ADR records the verified v0.4.5 behaviour.
32+
33+
## Decision
34+
35+
Pin the controller and CRDs to **v0.4.5 (`v1alpha1`)** for this PR series, and
36+
use the **direct `SandboxClaim.spec.sandboxTemplateRef → SandboxTemplate`**
37+
chain. Do not author `v1beta1`/`warmPoolRef` manifests against the v0.4.5
38+
install. Treat the migration to `v1beta1` (warmPoolRef and any related renames)
39+
as a separate, future ADR taken when we deliberately bump the controller.
40+
41+
## Options considered
42+
43+
- **Pin v0.4.5 (chosen):** reproducible, matches the validated CRD, direct
44+
chain needs no WarmPool for the cold-start path.
45+
- **Track `latest`:** rejected — the `v1alpha1 → v1beta1` claim-schema change
46+
would break tenant manifests at deploy time on the next upstream release.
47+
- **Author `v1beta1` now:** rejected — the installed CRD is v0.4.5; v1beta1
48+
manifests would be rejected by the API server.
49+
50+
## Consequences
51+
52+
**Positive**
53+
- Manifests validate against the exact installed CRD; deploys are reproducible.
54+
- The direct chain is simpler — no `SandboxWarmPool` object required on the
55+
cold-start path.
56+
57+
**Negative / open items**
58+
- We are pinned to an older API and must write a migration ADR before adopting
59+
newer controller features that only exist in `v1beta1`.
60+
- The v0.4.5 `warmpool` field defaults to `"default"`. **Open item:** confirm a
61+
`SandboxClaim` cold-starts with no pre-existing `SandboxWarmPool` named
62+
`default`, versus needing to create one. To be verified during PR #1 deploy
63+
verification (profile `hclo-mac`, `us-east-1`).
64+
65+
## References
66+
67+
- v0.4.5 CRD: `https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v0.4.5/extensions.yaml`
68+
- Current API reference (`v1beta1`, for contrast): https://agent-sandbox.sigs.k8s.io/docs/api/
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# ADR-0003: Render a per-tenant SandboxTemplate to carry ServiceAccount, Secret, PVC, and env
2+
3+
- **Status:** Proposed
4+
- **Date:** 2026-06-18
5+
- **Deciders:** HC Lo (hclo)
6+
- **Depends on:** [ADR-0002](0002-pin-agent-sandbox-v0.4.5.md)
7+
8+
## Context
9+
10+
Each tenant's OpenClaw workspace pod needs several **per-tenant, reference-typed**
11+
inputs that the current `Deployment` expresses today:
12+
13+
- a per-tenant **ServiceAccount** for EKS Pod Identity (zero-API-key Bedrock);
14+
- the gateway-token **Secret** `{fullname}-gateway-token` (today via `envFrom.secretRef`);
15+
- **`TENANT_NAMESPACE`** from the downward API (`fieldRef`);
16+
- a per-tenant data **PVC**;
17+
- three initContainers (config, skills, tools — including the IMDS
18+
`169.254.170.23` Pod Identity patch).
19+
20+
In the pinned v0.4.5 API (ADR-0002), `SandboxClaim.spec.env` is a list of plain
21+
`{name, value}` pairs — it has **no `valueFrom`**, so it cannot inject a Secret,
22+
a downward-API value, or a ServiceAccount. Those constructs only exist inside a
23+
**PodSpec**. The `SandboxTemplate.spec.podTemplate` *is* a full PodSpec and can
24+
express all of them — but a single shared template cannot carry per-tenant
25+
*names* (SA name, secret name, PVC).
26+
27+
The platform already renders per-tenant output through the ArgoCD
28+
`ApplicationSet` + Helm chart, so per-tenant rendering is a path we already own.
29+
30+
## Decision
31+
32+
Render a **per-tenant `SandboxTemplate`** through the existing ApplicationSet /
33+
Helm path (one template per tenant, named for the tenant). The template's
34+
`podTemplate` carries the per-tenant `serviceAccountName`, secret references,
35+
downward-API env, volumes, initContainers, and a `volumeClaimTemplates` entry
36+
for the data PVC. Each tenant's `SandboxClaim` references its own template by
37+
name.
38+
39+
Set **`envVarsInjectionPolicy: Allowed`** on the template (the v0.4.5 default is
40+
`Disallowed`) so that any future claim-level plain env is still accepted.
41+
42+
Consequently we do **not** keep "one shared template per runtime tier". The
43+
runtime tier (runc/gVisor) is selected by a field *within* each per-tenant
44+
template, driven by a Helm value (consistent with ADR-0005).
45+
46+
## Options considered
47+
48+
- **A. Per-tenant SandboxTemplate (chosen):** fits the existing per-tenant Helm
49+
rendering; carries SA/Secret/PVC/env via a real PodSpec.
50+
- **B. Single shared template + `claim.env`:** rejected — `claim.env` is plain
51+
`name/value` only; cannot inject the gateway-token Secret, the downward-API
52+
namespace, or a per-tenant ServiceAccount.
53+
- **C. One template per tier + controller naming-convention magic:** rejected —
54+
relies on undocumented controller behaviour for per-tenant SA/secret binding;
55+
high risk.
56+
57+
## Consequences
58+
59+
**Positive**
60+
- Per-tenant SA (Pod Identity), Secret, PVC, and env are all expressible.
61+
- Reuses the existing ApplicationSet render-per-tenant mechanism; little new
62+
machinery.
63+
64+
**Negative / open items**
65+
- More `SandboxTemplate` objects (one per tenant) instead of one per tier.
66+
Acceptable at this sample's tenant scale; revisit for very large fleets
67+
(a pool model is the large-scale variant).
68+
- The gVisor tier (ADR-0005) becomes a Helm value flipping `runtimeClassName`
69+
in the per-tenant template, rather than a second shared template.
70+
- agent-sandbox's secure default sets `automountServiceAccountToken: false`.
71+
**Open item:** confirm Pod Identity still works (it relies on the Pod Identity
72+
Agent + webhook, not the SA token mount). Verify during PR #1 deploy
73+
verification.
74+
75+
## References
76+
77+
- v0.4.5 `SandboxClaim` / `SandboxTemplate` schema — see [ADR-0002](0002-pin-agent-sandbox-v0.4.5.md)
78+
- [docs/agent-sandbox.md](../agent-sandbox.md)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# ADR-0004: Defer hibernation + sandbox-router; PR #1 keeps path-based routing and `operatingMode: Running`
2+
3+
- **Status:** Proposed
4+
- **Date:** 2026-06-18
5+
- **Deciders:** HC Lo (hclo)
6+
- **Depends on:** [ADR-0001](0001-adopt-agent-sandbox-model.md), [ADR-0003](0003-per-tenant-sandboxtemplate.md)
7+
8+
## Context
9+
10+
Today scale-to-zero is provided by the **KEDA HTTP add-on**: a per-tenant
11+
`HTTPScaledObject` targets the tenant `Deployment`, scales it 0↔1 on request
12+
rate (idle 900s), and its interceptor holds an inbound request while a
13+
scaled-to-zero tenant starts. Routing is **path-based**:
14+
`claw.example.com/t/<tenant>/` via a Gateway API `HTTPRoute`.
15+
16+
Adopting the Sandbox model (ADR-0001) removes the `Deployment`. A `Sandbox` is a
17+
singleton with no `/scale` subresource, so KEDA HTTP can no longer target it.
18+
The agent-sandbox model offers its own scale-to-zero via **hibernation**
19+
(pause on idle, "automatic resume on incoming network connections") and a
20+
**sandbox-router** for scalable access. We researched both before committing.
21+
22+
Verified findings that change the picture:
23+
24+
1. **sandbox-router is header-based, not path-based.** It routes by an
25+
`X-Sandbox-ID` (+ namespace/port) HTTP header to
26+
`<id>.<ns>.svc.cluster.local`. Our contract is path-based (`/t/<tenant>/`),
27+
sent by browsers/auth-ui. Stock Gateway API cannot easily translate a
28+
dynamic path segment into a header, so adopting the router changes the
29+
routing/UX contract — it is not a drop-in addition.
30+
2. **The router does not itself wake a hibernated sandbox.** Its own tests
31+
assert that an unreachable sandbox returns **502 Bad Gateway**. So the router
32+
is a reverse proxy, not an activator.
33+
3. **The wake/hibernation mechanism is unverified.** We could not find a doc
34+
that states whether hibernation *pauses* the pod (endpoint stays, connection
35+
can trigger resume) or *deletes* it (then who triggers resume?). The
36+
controller flags we found are concurrency-only; there is **no idle-timeout
37+
knob**, and `shutdownTime` is a delete-TTL, not idle-hibernate. The cost
38+
story (scale-to-zero) hinges entirely on this unknown.
39+
40+
## Decision
41+
42+
Split the scale-to-zero redesign out of PR #1.
43+
44+
**PR #1** runs each tenant Sandbox with **`operatingMode: Running`** (always-on,
45+
no hibernation). Routing stays **path-based**: the per-tenant `HTTPRoute`
46+
backend is repointed from the old Deployment Service to the **Sandbox's
47+
controller-created headless Service**. The KEDA `HTTPScaledObject` is **removed**
48+
in PR #1 (it targeted a Deployment that no longer exists and cannot target a
49+
Sandbox); we accept an always-on interim cost posture.
50+
51+
**The sandbox-router, hibernation, and the scale-to-zero replacement are
52+
deferred to PR #1.5**, gated on first resolving (a) the wake mechanism
53+
(pause-vs-delete and what triggers resume — to be answered by reading the
54+
controller source or by empirical test on the verification cluster) and (b) the
55+
path→header routing strategy. Each will be captured in its own ADR.
56+
57+
## Options considered
58+
59+
- **A. PR #1 keeps path routing + `Running`; defer router/hibernation (chosen):**
60+
isolates the verified, low-risk lifecycle adoption from the unresolved
61+
scale-to-zero redesign; PR #1 is deploy-verifiable now.
62+
- **B. Adopt sandbox-router + hibernation in PR #1:** rejected — wake mechanism
63+
unverified, router is header-based vs our path-based, router returns 502 not
64+
wake; high risk of shipping a broken scale-to-zero.
65+
- **C. Drop scale-to-zero permanently:** rejected — the cost story is a stated
66+
value of the sample. We defer, we do not abandon it.
67+
68+
## Consequences
69+
70+
**Positive**
71+
- PR #1 is shippable and deploy-verifiable without the unresolved hibernation
72+
question.
73+
- Routing and UX contract are unchanged for PR #1.
74+
75+
**Negative / interim**
76+
- Tenants run **always-on** in PR #1 → higher idle cost until PR #1.5. If useful,
77+
a `shutdownTime`/TTL can still bound truly-abandoned workspaces.
78+
- The original framing "hibernation replaces KEDA scale-to-zero" is **not yet
79+
proven**; PR #1.5 must validate it end-to-end before we claim the cost benefit.
80+
81+
## Resolution (2026-06-18, verified from controller source @ tag v0.4.5)
82+
83+
The pause-vs-delete question is settled by reading
84+
`controllers/sandbox_controller.go` at the v0.4.5 tag:
85+
86+
- v0.4.5 (`v1alpha1`) uses `Sandbox.Spec.Replicas` (0/1); there is **no**
87+
`operatingMode: Suspended` (that is a later `v1beta1` field).
88+
- When `Replicas == 0` the controller **deletes the pod**
89+
(`"Deleting Pod because .Spec.Replicas is 0"``r.Delete(ctx, pod)`), so the
90+
Service endpoint disappears. **This is Case B (delete), not pause.**
91+
- There is **no connection-triggered resume** anywhere in the v0.4.5 core or
92+
SandboxClaim controllers. The "automatic resume on incoming network
93+
connections" described in the current docs is a later (`v1beta1`) capability,
94+
not present in v0.4.5.
95+
96+
**Consequence for KEDA:** on v0.4.5, the Sandbox model does **not** replace
97+
KEDA's scale-to-zero at all — it provides isolation + lifecycle + identity only.
98+
Scale-to-zero with wake still requires both an idle scaler and an activator
99+
(KEDA's two roles). The sandbox-router does not wake (returns 502).
100+
101+
**Therefore PR #1 ships always-on (`Replicas=1`, KEDA removed), and the
102+
scale-to-zero path forks into three options for a later decision:**
103+
104+
1. **No scale-to-zero** — accept always-on (simplest).
105+
2. **Custom activator on v0.4.5** — a path-native component that patches
106+
`Sandbox.Spec.Replicas` 0↔1 on idle/request (we own and maintain it).
107+
3. **Migrate to v1beta1** — gain native hibernation + wake-on-connection, but
108+
the claim schema changes to `warmPoolRef` (re-do ADR-0002/0003); larger
109+
migration.
110+
111+
This decision is deferred to its own ADR; it does not block PR #1.
112+
113+
## References
114+
115+
- Sandbox Router README: `https://github.com/kubernetes-sigs/agent-sandbox/blob/main/clients/python/agentic-sandbox-client/sandbox-router/README.md`
116+
- Controller configuration flags: `https://github.com/kubernetes-sigs/agent-sandbox/blob/main/docs/configuration.md`
117+
- [docs/agent-sandbox.md](../agent-sandbox.md) — original KEDA→hibernation framing (revised by this ADR)

0 commit comments

Comments
 (0)