mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-23 11:53:08 -05:00
345 lines
16 KiB
Markdown
345 lines
16 KiB
Markdown
# Opencode-serve transport: deploy & runtime gotchas
|
|
|
|
**Audience:** future engineers (and agents) deploying or debugging the
|
|
opencode-serve transport on a real Kubernetes cluster. Captures the
|
|
failure modes hit during the first production-cluster rollout and the
|
|
institutional knowledge about how the opencode-serve provider chain
|
|
wires up at runtime.
|
|
|
|
**Status:** the rollout succeeded after the workarounds below. The
|
|
underlying defects (RBAC gap, mutable-tag image cache, env-var refresh
|
|
race) are real and should be fixed in source rather than patched per
|
|
environment.
|
|
|
|
---
|
|
|
|
## 1. Things that broke (in the order they were hit)
|
|
|
|
### 1.1 RBAC gap: `secrets` not in `api-server-role`
|
|
|
|
**Symptom:**
|
|
|
|
```
|
|
Session creation failed: (403) Forbidden
|
|
secrets "sandbox-<id>-opencode-auth" is forbidden:
|
|
User "system:serviceaccount:onyx:onyx-workload-access" cannot
|
|
get resource "secrets" in API group "" in the namespace "onyx-sandboxes"
|
|
```
|
|
|
|
**Root cause:** the api server's Role in the sandbox namespace was
|
|
authored before the opencode-serve transport landed. It only granted
|
|
verbs on `pods`, `pods/exec`, and `services`. The new transport adds a
|
|
per-pod K8s `Secret` (`sandbox-<id>-opencode-auth`) holding the
|
|
`OPENCODE_CONFIG_CONTENT` and `OPENCODE_SERVER_PASSWORD` env values; the
|
|
api server `create`/`get`/`replace`/`delete`s it during provision and
|
|
cleanup.
|
|
|
|
**Fix:** add the verbs the api server actually uses to whatever defines
|
|
the Role in your deployment system.
|
|
|
|
```yaml
|
|
- apiGroups: [""]
|
|
resources: ["secrets"]
|
|
verbs: ["create", "get", "update", "delete"]
|
|
```
|
|
|
|
These are exactly the four verbs the api server exercises — and no more.
|
|
Map each to the `_core_api.` call that needs it:
|
|
|
|
| call (`kubernetes_sandbox_manager.py`) | HTTP method | RBAC verb |
|
|
| -------------------------------------- | ----------- | --------- |
|
|
| `create_namespaced_secret` | POST | `create` |
|
|
| `read_namespaced_secret` | GET | `get` |
|
|
| `replace_namespaced_secret` | PUT | `update` |
|
|
| `delete_namespaced_secret` | DELETE | `delete` |
|
|
|
|
Watch out: `replace_namespaced_secret` is a **PUT**, which maps to the
|
|
`update` verb — **not** `patch`. If you grant `patch` instead of
|
|
`update`, the happy path still works but the 409-race re-provision
|
|
branch (see §1.3 — the path that calls `replace_namespaced_secret` after
|
|
a create conflict) will intermittently 403. `list`/`watch` are not used
|
|
on secrets at all.
|
|
|
|
`backend/onyx/server/features/build/sandbox/kubernetes/kubernetes_sandbox_manager.py`
|
|
is the source of truth for which k8s resources the api server touches —
|
|
grep for `_core_api.` method calls if you need to re-derive the verb set.
|
|
|
|
### 1.2 Mutable tags + cached node images
|
|
|
|
**Symptom:** after pushing a new sandbox image with a mutable tag
|
|
(`latest`, `beta`, or `edge`),
|
|
freshly-provisioned sandbox pods kept running the **old** image.
|
|
opencode-serve was not listening on `:4096`; the container appeared to
|
|
be running the prior placeholder behavior.
|
|
|
|
**Root cause:** sandbox pods are created with
|
|
`imagePullPolicy: IfNotPresent` by default. When the configured image
|
|
reference is mutable, the kubelet sees an image with that tag already
|
|
cached on the node and skips the pull.
|
|
|
|
**Fix:** for Kubernetes, use app-aligned immutable tags, or set
|
|
`SANDBOX_IMAGE_PULL_POLICY=Always` when deliberately running a moving tag.
|
|
For Docker compose, the Docker sandbox manager refreshes moving sandbox tags
|
|
once per API process because sandbox containers are created outside compose.
|
|
|
|
### 1.3 `OPENCODE_CONFIG_CONTENT` env doesn't refresh on Secret update
|
|
|
|
**Symptom:** opencode-serve kept reporting `Model not found:
|
|
anthropic/claude-opus-4-7` even after:
|
|
|
|
- adding the missing `build-mode-anthropic` admin LLM provider,
|
|
- letting the api server re-provision the Secret (verified via
|
|
`kubectl get secret … -o jsonpath='{.data.config}' | base64 -d` to
|
|
show both providers in the new content).
|
|
|
|
But `kubectl exec sandbox-… -- env | grep OPENCODE_CONFIG_CONTENT`
|
|
still showed the **old, single-provider** value.
|
|
|
|
**Root cause:** Kubernetes does not propagate `Secret` updates to env
|
|
vars on running pods. Env from `secretKeyRef` is a snapshot taken at
|
|
container start. Only **pod deletion** (or recreate-replace at the pod
|
|
level) forces the kubelet to read the current Secret.
|
|
|
|
**Symptom amplification:** opencode-serve reads `OPENCODE_CONFIG_CONTENT`
|
|
**directly at startup** and uses that env value as the source of truth
|
|
for its provider registry. Stale env → stale opencode provider list.
|
|
`_provision_opencode_secret` rebuilds the Secret correctly, but no code
|
|
path in the api server deletes the pod afterward.
|
|
|
|
**Recovery:** `kubectl delete pod sandbox-<id>` after any admin
|
|
LLM-provider change that needs to reach an already-provisioned sandbox.
|
|
The api server's normal reconciliation (health check fails → terminate
|
|
→ re-provision) recreates the pod, which loads fresh env from the
|
|
current Secret.
|
|
|
|
**Source-level fix (deferred):** when the api server detects that
|
|
`all_llm_configs` has changed vs. the deployed sandbox, it should
|
|
delete the pod (forcing env refresh), not just `replace_namespaced_secret`.
|
|
|
|
### 1.4 `_get_all_llm_configs` only picks up `build-mode-*` providers
|
|
|
|
**Symptom:** admin had an Anthropic provider configured with
|
|
`name: "anthropic"`. opencode prompts for `providerID: anthropic`
|
|
returned `ProviderModelNotFoundError`. The sandbox's
|
|
`OPENCODE_CONFIG_CONTENT` only contained `openai`.
|
|
|
|
**Root cause:** `fetch_all_build_mode_llm_providers` in
|
|
`backend/onyx/server/features/build/db/build_session.py` filters with
|
|
`LLMProviderModel.name.like("build-mode-%")`. Providers without the
|
|
`build-mode-` name prefix are silently dropped during sandbox
|
|
provisioning.
|
|
|
|
**Fix:** add a second `LLMProvider` row with `name = "build-mode-anthropic"`
|
|
(the `provider` type stays `anthropic`, same API key, same visible
|
|
models). Same pattern for any other provider you want preloaded.
|
|
|
|
**Why the prefix exists:** it discriminates the craft-mode LLM provider
|
|
set from the regular chat product's providers so API keys and model
|
|
visibility don't bleed across products.
|
|
|
|
### 1.5 `_get_all_llm_configs` de-dups by provider type, not by row
|
|
|
|
When the user's BuildSession has `agent_provider="anthropic"`, the
|
|
default `llm_config` IS anthropic. `_get_all_llm_configs` then does
|
|
roughly:
|
|
|
|
```python
|
|
configs = [default]
|
|
seen_providers = {default.provider} # {"anthropic"}
|
|
for provider in fetch_all_build_mode_llm_providers(db):
|
|
if provider.provider in seen_providers: # build-mode-anthropic → "anthropic" → skip
|
|
continue
|
|
…
|
|
```
|
|
|
|
So the multi-provider list collapses back to `[anthropic_config]` if
|
|
the only other `build-mode-*` row is also anthropic. To have a sandbox
|
|
preloaded with two providers, you need two `build-mode-*` rows with
|
|
**different** `provider` types.
|
|
|
|
### 1.6 Bus self-close residue (already shipped)
|
|
|
|
`51b780b9f8` — `PodEventBus.closed` property + eviction in
|
|
`_get_or_create_event_bus`. Mentioned here so future readers
|
|
inspecting the bus lifecycle understand the existing safety net: when
|
|
a bus exhausts its reconnect budget (20 consecutive failures), it
|
|
self-closes; the next `_get_or_create_event_bus` call evicts it and
|
|
builds a fresh one with the current Secret's password.
|
|
|
|
### 1.7 Cold-pod connection errors during `ensure_session` (already shipped)
|
|
|
|
`e6e8a109e9` — 3-attempt retry with linear backoff on
|
|
`httpx.ConnectError`/`RemoteProtocolError` in
|
|
`_http_with_cold_pod_retry`. Subsequently tightened in `28fd724314` —
|
|
POST `/session` retries only on `ConnectError` (not
|
|
`RemoteProtocolError`) to avoid an orphan-session leak.
|
|
|
|
---
|
|
|
|
## 2. How the opencode provider chain actually wires up
|
|
|
|
This is the load-bearing institutional knowledge. Without it the bugs
|
|
in §1 look like a random pile of subsystems; with it they're obvious in
|
|
advance.
|
|
|
|
### 2.1 End-to-end data flow
|
|
|
|
1. **Admin UI** writes `llm_provider` rows. The `name` field controls
|
|
whether the row participates in craft (`build-mode-*` pattern).
|
|
`provider` is the type (`anthropic`, `openai`, `openrouter`, …).
|
|
2. **BuildSession created** → `agent_provider` / `agent_model` record
|
|
the user's selection. Defaults come from `fetch_default_llm_model()`.
|
|
3. **Sandbox provisioning** (`KubernetesSandboxManager.provision`):
|
|
- `_get_llm_config(requested_provider_type, requested_model_name)`
|
|
resolves the **default** for this sandbox. Tries
|
|
`fetch_llm_provider_by_type_for_build_mode(provider_type)` first
|
|
(which prefers `build-mode-{type}` then falls back to any provider
|
|
of that type). Returns an `LLMProviderConfig`.
|
|
- `_get_all_llm_configs(default=llm_config)` builds the full list:
|
|
`[default]` + every other `build-mode-*` provider whose `provider`
|
|
type isn't already in `seen_providers` and that has at least one
|
|
`is_visible=True` model.
|
|
- `build_multi_provider_opencode_config(providers, default_provider,
|
|
default_model, disabled_tools)` produces the opencode.json shape:
|
|
`{$schema, model, provider: {<type>: {options.apiKey, models}},
|
|
enabled_providers, permission}`.
|
|
- The JSON is `json.dumps`'d and written to a per-pod K8s Secret
|
|
(`sandbox-<id>-opencode-auth`, key `config`) alongside the per-pod
|
|
`password` (HTTP Basic auth for opencode-serve).
|
|
4. **Pod spec** references the Secret via two `secretKeyRef` env
|
|
entries: `OPENCODE_CONFIG_CONTENT` and `OPENCODE_SERVER_PASSWORD`.
|
|
**k8s does NOT propagate Secret updates to running pod envs** — see
|
|
§1.3.
|
|
5. **Entrypoint** runs `opencode serve --hostname 0.0.0.0 --port
|
|
"$OPENCODE_SERVE_PORT" --print-logs` in a `while true` restart loop.
|
|
It does NOT materialize `OPENCODE_CONFIG_CONTENT` to a file — the
|
|
env var is opencode's primary config source (opencode also probes
|
|
filesystem paths but the env path is what craft uses).
|
|
6. **opencode-serve** registers providers **lazily** on the first
|
|
prompt. Watch the logs for the sequence:
|
|
```
|
|
service=provider status=started
|
|
service=provider providerID=<X> found
|
|
service=provider status=completed
|
|
```
|
|
A missing `found` line for a provider means opencode silently
|
|
dropped it from the registry.
|
|
7. **Per-prompt model override:** the api server's `_post_prompt_async`
|
|
sets `body["model"] = {"providerID": …, "modelID": …}` when both are
|
|
set on the BuildSession. opencode looks up `providerID/modelID` in
|
|
its merged registry; if the providerID isn't registered →
|
|
`ProviderModelNotFoundError`.
|
|
|
|
### 2.2 Auth
|
|
|
|
- `OPENCODE_SERVER_USERNAME = "opencode"` (constant in
|
|
`backend/onyx/server/features/build/configs.py`). **Not** `"onyx"` —
|
|
this tripped up curl-based debugging.
|
|
- Password comes from the `OPENCODE_SERVER_PASSWORD` env (mounted from
|
|
the per-pod Secret). Both the api server (HTTP Basic) and any
|
|
in-pod curl debugging need this exact username.
|
|
|
|
### 2.3 Common error signatures
|
|
|
|
| Error / log line | Meaning |
|
|
|---|---|
|
|
| `secrets … is forbidden … namespace "onyx-sandboxes"` | §1.1 — api-server Role missing `secrets` verbs |
|
|
| `[Errno 111] Connection refused` on POST `/session` | opencode-serve not bound to `:4096`. Cold pod, crashloop, or process death |
|
|
| `ProviderModelNotFoundError` w/ bundled-model suggestions | Provider not registered in opencode (silent drop from env config), or config not loaded |
|
|
| `Sandbox … has status provisioning and is being created by another request` | Intentional guard. Two concurrent requests on the same user's mid-provision sandbox |
|
|
| Sandbox pod shows old behavior despite new image push | Cached mutable-tag digest on the node + `IfNotPresent` policy (§1.2) |
|
|
| `opencode /event stream did not become ready` | Bus reader failed to subscribe before the per-turn deadline. Causes: opencode-serve not bound yet (cold), or auth mismatch between bus's cached password and current Secret (bus will self-close and rebuild on next prompt — see §1.6) |
|
|
| opencode-serve restarts in tight loop, exit 143 | SIGTERM propagated from the entrypoint trap. Almost always operator-induced |
|
|
|
|
### 2.4 Diagnostic snippets
|
|
|
|
**What providers does THIS sandbox actually have loaded?**
|
|
```bash
|
|
SBX=$(kubectl -n onyx-sandboxes get pods -o name | head -1 | sed 's|pod/||')
|
|
kubectl -n onyx-sandboxes exec "$SBX" -c sandbox -- sh -c 'echo "$OPENCODE_CONFIG_CONTENT"' \
|
|
| python3 -c 'import sys, json; d=json.load(sys.stdin); print("enabled:", d.get("enabled_providers")); print("keys:", list(d.get("provider",{}).keys()))'
|
|
```
|
|
|
|
**What does opencode-serve think it loaded?** Tail the sandbox
|
|
container logs and look for `service=provider providerID=<X> found`
|
|
lines after the first prompt. If a provider is in the env var but
|
|
missing from this log, opencode silently dropped it (usually a JSON
|
|
shape problem or missing API key).
|
|
|
|
**Test the opencode API directly** (auth user is `opencode`, NOT `onyx`):
|
|
```bash
|
|
kubectl -n onyx-sandboxes exec sandbox-<id> -c sandbox -- sh -c '
|
|
curl -s -u "opencode:$OPENCODE_SERVER_PASSWORD" http://localhost:4096/doc \
|
|
| python3 -m json.tool | head -20
|
|
'
|
|
```
|
|
|
|
**Tail opencode logs across pods (with `stern`):**
|
|
```bash
|
|
stern -n onyx-sandboxes sandbox -c sandbox
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Recovery recipes
|
|
|
|
### Symptoms cluster A: "my admin LLM provider isn't reaching the sandbox"
|
|
|
|
1. Confirm the provider's `name` matches `build-mode-%`:
|
|
```sql
|
|
SELECT id, name, provider, default_model_name FROM llm_provider;
|
|
```
|
|
If not, rename (or create a duplicate `build-mode-*` row pointing at
|
|
the same API key). The `provider` field stays as the type.
|
|
2. Confirm at least one `model_configuration` has `is_visible=true` for
|
|
that provider — silently skipped otherwise.
|
|
3. Delete the user's sandbox pod to force env refresh from the Secret
|
|
(§1.3). The api server's reconciliation will re-provision with the
|
|
updated provider list.
|
|
4. Tail opencode logs for `service=provider providerID=<X> found` after
|
|
the first prompt. If missing, the provider got dropped — recheck
|
|
the JSON shape in `OPENCODE_CONFIG_CONTENT`.
|
|
|
|
### Symptoms cluster B: "new sandbox image isn't being used"
|
|
|
|
1. Check the pod's actual image digest:
|
|
```bash
|
|
kubectl -n onyx-sandboxes get pod sandbox-<id> \
|
|
-o jsonpath='{.status.containerStatuses[?(@.name=="sandbox")].imageID}'
|
|
```
|
|
2. Compare with the digest of the image you just pushed.
|
|
3. If they differ, the node has a cached older image and the pod is
|
|
running that. For Kubernetes, deploy a matching immutable app/sandbox tag or
|
|
set `SANDBOX_IMAGE_PULL_POLICY=Always` for a moving-tag environment, then
|
|
recreate the affected sandbox pod.
|
|
|
|
### Symptoms cluster C: "RBAC 403 on a sandbox-namespace resource"
|
|
|
|
The api server's Role in the sandbox namespace is out of date — see
|
|
§1.1. Cross-reference current Role rules against the k8s API calls
|
|
made from `kubernetes_sandbox_manager.py` (grep for `_core_api.` /
|
|
`_rbac_api.` method names) to derive the verb set the api server
|
|
actually needs.
|
|
|
|
---
|
|
|
|
## 4. Architectural follow-ups
|
|
|
|
| Item | Why it matters | Where to land it |
|
|
|---|---|---|
|
|
| App-aligned sandbox tags | Avoids app/sandbox version skew; Kubernetes immutable tags avoid mutable-tag cache traps without adding a registry check to every sandbox pod start | release workflow + sandbox PodTemplate defaults |
|
|
| Recreate pod on Secret content change | Fixes §1.3 — env doesn't refresh on Secret update | `_provision_opencode_secret` or the provisioning caller |
|
|
| `secrets` verbs in the chart-managed sandbox-namespace Role | Stops the §1.1 403 from recurring on every fresh deploy | Onyx helm chart's sandbox-rbac template |
|
|
| Pre-seed `build-mode-*` providers in admin onboarding | Avoid the silent-skip filter trap in §1.4 | Onyx admin / setup wizard |
|
|
| Surface "sandbox provisioning" via 409 + Retry-After | Instead of `RuntimeError` → JSON dump, return a clean wait/retry signal the FE can poll on | `session/manager._stream_cli_agent_response` |
|
|
| Wait for opencode-serve `/doc` before reporting RUNNING | Closes the "k8s-ready but opencode-not-bound" window where the first prompt races a cold opencode | `KubernetesSandboxManager.provision` (landed alongside this doc) |
|
|
|
|
---
|
|
|
|
## 5. Provenance
|
|
|
|
Captured during the first production-cluster rollout of the
|
|
opencode-serve transport. The imperative recovery commands
|
|
above were validated against a single-tenant cluster; the source-level
|
|
fixes are tracked in §4.
|