Skip to content

Commit e5adf6f

Browse files
authored
feat(supervisor): recover shared instances with hash-based HA (#4864)
* feat(supervisor): recover shared instances with hash-based HA Signed-off-by: Drew Newberry <anewberry@nvidia.com> * refactor(supervisor): keep HA coordination out of compute drivers Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(supervisor): fence pool admission and reconciliation races Signed-off-by: Drew Newberry <anewberry@nvidia.com> * feat(supervisor): keep pool mode stable across replica scaling Signed-off-by: Drew Newberry <anewberry@nvidia.com> * refactor(supervisor): default shared deployments to pool mode Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(supervisor): fence refused launches and remove legacy mode Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(supervisor): confirm live owners and fence stale observations Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(supervisor): preserve takeover cause log formatting Signed-off-by: Drew Newberry <anewberry@nvidia.com> * feat(helm): add memory-based supervisor autoscaling Signed-off-by: Drew Newberry <anewberry@nvidia.com> * fix(supervisor): retain acknowledgements and fence streamed input Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(supervisor): consolidate experimental multi-tenant guidance Signed-off-by: Drew Newberry <anewberry@nvidia.com> * docs(supervisor): simplify multi-tenant supervisor guide Signed-off-by: Drew Newberry <anewberry@nvidia.com> * refactor(supervisor): simplify pool member identity Signed-off-by: Drew Newberry <anewberry@nvidia.com> * feat(helm): make supervisor affinity configurable Signed-off-by: Drew Newberry <anewberry@nvidia.com> * feat(helm): make supervisor disruption budget optional Signed-off-by: Drew Newberry <anewberry@nvidia.com> * test(supervisor): align HA fixtures with suspend support Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent f07127e commit e5adf6f

96 files changed

Lines changed: 21191 additions & 1363 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/helm-dev-environment/SKILL.md‎

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ gatewayConfig:
6868
supervisor_topology: gateway
6969
```
7070
71-
The gateway chart creates a single-replica shared supervisor Deployment,
72-
Service, NetworkPolicy, and an empty `<release>-supervisor-token` Secret in
71+
The gateway chart creates a shared supervisor Deployment (`supervisor.replicas`,
72+
default one), Service, NetworkPolicy, and an empty
73+
`<release>-supervisor-token` Secret in
7374
the release namespace, regardless of shared/managed/operator workspace mode.
7475
The gateway mints a supervisor token from its sandbox-JWT signing key, writes
7576
it into that Secret (the gateway Role may `get`/`patch` only that Secret), and
@@ -96,6 +97,104 @@ OPENSHELL_E2E_KUBE_IMAGE_PULL_SECRET=e2e-regcred \
9697
mise run e2e:kubernetes:shared-supervisor
9798
```
9899

100+
### Shared supervisor high availability
101+
102+
The shared supervisor is always a pool of `supervisor.replicas` members
103+
(default 1), or an HPA-managed pool when `supervisor.autoscaling.enabled=true`.
104+
`ci/values-supervisor-autoscaling.yaml` enables the memory HPA (default `512Mi`
105+
average per Pod, two to four members, one removal every two minutes after five
106+
minutes of stabilization). It requires the resource metrics API, usually
107+
Metrics Server. The HPA omits the Deployment's fixed replica count and ignores
108+
`supervisor.replicas`; top-level `autoscaling` still controls only the gateway.
109+
Do not combine the HPA overlay with the manual 1→2→1 scaling E2E: that test
110+
expects Helm to own replicas. Changing HPA bounds or target must leave the
111+
gateway config and supervisor Pod template unchanged, as checked by
112+
`deploy/helm/test-supervisor-pool-scaling.sh`. With the gateway topology the
113+
chart renders the gateway's
114+
`[openshell.gateway.supervisor_pool]` tuning table (only
115+
`recovery_timeout_secs` from `supervisor.recoveryTimeoutSeconds`, default 60;
116+
the driver's `shared_supervisor` table stays unchanged), a
117+
RollingUpdate Deployment (`maxSurge: 1`, `maxUnavailable: 0`) with a 60-second
118+
grace period, no affinity unless `supervisor.affinity` is set (top-level
119+
`affinity` applies only to the gateway), the pool identity env vars
120+
(`OPENSHELL_SUPERVISOR_POOL_MEMBER_ID` and `_ADVERTISE_IP` from the Pod name
121+
and IP), and a `maxUnavailable: 1` PodDisruptionBudget unless
122+
`supervisor.podDisruptionBudget.enabled=false` (default `true`; toggling it
123+
changes only the PodDisruptionBudget). Each supervisor resource has its own
124+
template: `supervisor.yaml` (Deployment), `supervisor-service.yaml`,
125+
`supervisor-networkpolicy.yaml`, `supervisor-token-secret.yaml`,
126+
`supervisor-pdb.yaml`, and `supervisor-hpa.yaml`.
127+
`ci/values-supervisor-ha.yaml` selects two replicas and a 60-second recovery
128+
timeout. The topology needs gateway TLS: the e2e harness keeps it on with
129+
`ci/values-tls-port-forward.yaml` and `OPENSHELL_E2E_KUBE_TLS=1`, which reaches
130+
the gateway over mTLS through its port-forward. `mise run
131+
e2e:kubernetes:shared-supervisor-ha` sets both, creates a task-scoped k3d
132+
cluster, kills the supervisor Pod that owns a sandbox, and checks that the
133+
sandbox keeps its Pod, generation, main process, and in-memory state.
134+
135+
The rendered gateway configuration and supervisor Pod template do not
136+
depend on `supervisor.replicas`; `deploy/helm/test-supervisor-pool-scaling.sh`
137+
(part of `mise run helm:test`) renders a pool at 1, 2, and 3 replicas and
138+
fails if anything but the replica count differs. `mise run
139+
e2e:kubernetes:shared-supervisor-scaling` installs a one-member pool
140+
(`ci/values-supervisor-ha-scaling.yaml`), then the test runs `helm upgrade
141+
--reuse-values --set supervisor.replicas=2` and back to 1 against the chart
142+
the harness installed (`OPENSHELL_E2E_KUBE_CHART`), probing exec and
143+
policy-mediated networking throughout. It checks that scaling up moves
144+
nothing, that the member removed by scaling down hands its sandboxes to the
145+
other (`cause=leaving`), that no workload, gateway Pod, or remaining member
146+
restarts, and that a new sandbox runs after each step. It upgrades its
147+
release, so it runs on a release of its own; the two pool tests also share a
148+
nextest test group that never runs them at once. Against an existing cluster
149+
whose kubeconfig the shell environment may override, set
150+
`OPENSHELL_E2E_KUBECONFIG` (passed as `--kubeconfig` to kubectl and helm).
151+
152+
On k3d, deploy the pool with the Skaffold profile, which layers the overlay and
153+
turns gateway TLS back on over `ci/values-skaffold.yaml`:
154+
155+
```bash
156+
cd deploy/helm/openshell
157+
KUBECONFIG=../../../kubeconfig skaffold run -p shared-supervisor-high-availability
158+
cd ../../..
159+
```
160+
161+
The plaintext registration from `helm:skaffold:run` does not apply. Keep
162+
`mise run helm:k3s:forward` running and set up the client certificate as in
163+
"With mTLS enabled" under
164+
[Connecting through the forwarding task](#connecting-through-the-forwarding-task).
165+
Both members share the single k3d node, so exercise Pod failures:
166+
167+
```bash
168+
export KUBECONFIG=kubeconfig OPENSHELL_GATEWAY_ENDPOINT=https://localhost:8090
169+
openshell sandbox create --detach --name ha-probe -- sh -c 'echo $$ > /sandbox/main.pid; exec sleep infinity'
170+
openshell sandbox exec --name ha-probe --no-tty -- cat /sandbox/main.pid
171+
openshell sandbox get ha-probe # note the sandbox ID
172+
# Supervisor logs name the instance by its route, not its sandbox ID.
173+
kubectl get sandboxes.agents.x-k8s.io -A -l openshell.ai/sandbox-name=ha-probe \
174+
-o jsonpath='{.items[0].metadata.annotations.openshell\.ai/sandbox-runtime-supervisor-session}'
175+
# The member whose latest admission, removal, or release line names the route owns it.
176+
kubectl -n openshell logs -l app.kubernetes.io/name=openshell-supervisor --prefix --tail=-1 \
177+
| grep -E '(admitted assigned|removing withdrawn|relinquishing a) sandbox instance.* <route>$'
178+
# Force-delete the owner, the closest local stand-in for a crash.
179+
kubectl -n openshell delete pod <owner-pod> --grace-period=0 --force
180+
kubectl -n openshell logs -l app.kubernetes.io/name=openshell,app.kubernetes.io/instance=openshell \
181+
-c openshell-gateway --prefix --tail=200 | grep 'taking over a shared sandbox instance'
182+
kubectl -n openshell logs -l openshell.ai/sandbox-id=<sandbox-id>,openshell.ai/boundary-role=workload \
183+
--tail=200 | grep -E 'sandbox-supervisor-|Supervisor takeover confirmed'
184+
openshell sandbox exec --name ha-probe --no-tty -- cat /sandbox/main.pid # same PID
185+
```
186+
187+
A plain `kubectl delete pod` exercises the graceful leave instead: the member
188+
announces it is leaving and the gateway hands its instances over at once. A
189+
member that stops without announcing it, as on node loss, is declared dead
190+
only after the 15-second membership TTL.
191+
`kubectl -n openshell rollout restart deployment/openshell-supervisor` replaces
192+
every member one at a time. The PID must survive each case, while an open
193+
`sandbox connect` or `exec` stream breaks and must reconnect. A
194+
`sandbox-supervisor-recovery-expired` finding in the workload log means no
195+
replacement confirmed within the recovery timeout, so the runtime terminated
196+
the workload.
197+
99198
### 2. Deploy OpenShell
100199

101200
**Iterative dev** (rebuilds on file changes, recommended during active development):
@@ -498,6 +597,8 @@ for dependencies still declared in `Chart.yaml`.
498597
| `deploy/helm/openshell/ci/values-cert-manager.yaml` | cert-manager PKI overlay (opt-in; disables pkiInitJob) |
499598
| `deploy/helm/openshell/ci/values-gateway.yaml` | Envoy Gateway GRPCRoute + Gateway overlay |
500599
| `deploy/helm/openshell/ci/values-high-availability.yaml` | HA test overlay (`replicaCount: 2` with external PostgreSQL Secret) |
600+
| `deploy/helm/openshell/ci/values-supervisor.yaml`, `ci/values-supervisor-ha.yaml`, `ci/values-supervisor-ha-scaling.yaml` | Shared supervisor pool overlays: one member, two members (Skaffold profile `shared-supervisor-high-availability`), or one member to scale to two and back |
601+
| `deploy/helm/openshell/ci/values-tls-port-forward.yaml` | Keeps gateway TLS on over the Skaffold values for e2e topologies that need it (`OPENSHELL_E2E_KUBE_TLS=1`) |
501602
| `deploy/helm/openshell/ci/values-autoscaling.yaml` | Render-only overlay for the optional gateway HorizontalPodAutoscaler (helm lint and helm-unittest) |
502603
| `deploy/helm/openshell/ci/values-keycloak.yaml` | Keycloak OIDC overlay |
503604
| `deploy/helm/openshell/ci/values-spire.yaml` | SPIFFE/SPIRE provider token grant overlay |

‎.config/nextest.toml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,26 @@ slow-timeout = { period = "60s", terminate-after = 5 }
2626
# names because binary() fails in workspaces without the HA test binary.
2727
[test-groups]
2828
kubernetes-ha = { max-threads = 1 }
29+
kubernetes-shared-supervisor-pool = { max-threads = 1 }
2930

3031
[[profile.e2e-kubernetes.overrides]]
3132
filter = "test(/gateway_(scale_and_rollout|pod_rolls)$/)"
3233
test-group = "kubernetes-ha"
3334

35+
# Both change the shared supervisor pool of the release they run against: one
36+
# kills a member, the other scales the pool. Never run them at once. Test
37+
# names, not binary(), for the reason above.
38+
[[profile.e2e-kubernetes.overrides]]
39+
filter = "test(/^(losing_a_supervisor_pod_moves_its_sandboxes_without_restarting_them|scaling_a_supervisor_pool_up_and_down_keeps_its_sandboxes_running)$/)"
40+
test-group = "kubernetes-shared-supervisor-pool"
41+
42+
# Scaling the pool runs two bounded Helm upgrades (up to 6 minutes each), waits
43+
# for each member to join or leave, and creates sandboxes until one lands on
44+
# the new member, so it gets 20 minutes instead of five.
45+
[[profile.e2e-kubernetes.overrides]]
46+
filter = "test(/^scaling_a_supervisor_pool_up_and_down_keeps_its_sandboxes_running$/)"
47+
slow-timeout = { period = "60s", terminate-after = 20 }
48+
3449
# Relative to the profile store dir (`e2e/rust/target/nextest/e2e-kubernetes/`).
3550
[profile.e2e-kubernetes.junit]
3651
path = "../../../../../results/e2e-kubernetes.xml"

‎Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎TESTING.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,9 @@ Available task variants:
619619
| `e2e:kubernetes:workspace-operator` | Operator workspace mode (pre-provisioned namespaces) |
620620
| `e2e:kubernetes:v1alpha1` | Agent Sandbox v1alpha1 compatibility |
621621
| `e2e:kubernetes:external-driver` | External Kubernetes driver sidecar |
622+
| `e2e:kubernetes:shared-supervisor` | One-member shared supervisor pool for the gateway's sandboxes (gateway TLS through the port-forward) |
623+
| `e2e:kubernetes:shared-supervisor-ha` | Two-replica shared supervisor pool: kills the member that owns a sandbox and checks the sandbox is adopted, not restarted |
624+
| `e2e:kubernetes:shared-supervisor-scaling` | One-member shared supervisor pool: scales it to two members and back with `helm upgrade`, checking sandboxes stay running and the removed member hands its sandboxes off |
622625

623626
Kubernetes e2e environment variables:
624627

@@ -627,6 +630,8 @@ Kubernetes e2e environment variables:
627630
| `OPENSHELL_E2E_KUBE_CONTEXT` | kubectl context for an existing cluster (skips k3d creation) |
628631
| `OPENSHELL_E2E_KUBE_TEST` | Scope to a single test (e.g. `smoke`) |
629632
| `OPENSHELL_E2E_KUBE_EXTRA_VALUES` | Colon-separated additional Helm values files |
633+
| `OPENSHELL_E2E_KUBE_CHART`, `OPENSHELL_E2E_KUBECONFIG` | Set by the harness for tests that upgrade the release, such as the supervisor pool scaling test: the chart it installed, and the kubeconfig passed as `--kubeconfig` to kubectl and helm. Set them yourself when running such a test directly against an existing release |
634+
| `OPENSHELL_E2E_KUBE_TLS` | `1` reaches the gateway over mTLS through the port-forward; pair it with an overlay that keeps gateway TLS on, such as `ci/values-tls-port-forward.yaml` |
630635
| `OPENSHELL_E2E_KUBERNETES_FEATURES` | Cargo feature flags (default: `e2e,e2e-host-gateway,e2e-kubernetes`) |
631636
| `IMAGE_TAG` | Gateway/supervisor image tag (default: `latest` for existing clusters) |
632637
| `OPENSHELL_REGISTRY` | Image registry prefix (default: `ghcr.io/nvidia/openshell`) |

0 commit comments

Comments
 (0)