@openhands/extensions 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.github/workflows/check-extensions.yml +4 -0
  2. package/.github/workflows/pr-artifacts.yml +294 -0
  3. package/.github/workflows/pr-description-check.yml +4 -2
  4. package/.release-please-manifest.json +1 -1
  5. package/AGENTS.md +28 -4
  6. package/README.md +5 -2
  7. package/automations/README.md +52 -36
  8. package/automations/bundle-index.js +13 -3
  9. package/automations/catalog/custom-automation/manifest.json +229 -0
  10. package/automations/catalog/github-agents-md-maintainer/manifest.json +2 -1
  11. package/automations/catalog/github-issue-to-pr/manifest.json +3 -2
  12. package/automations/catalog/github-issue-triage/manifest.json +72 -0
  13. package/automations/catalog/github-pr-reviewer/manifest.json +3 -2
  14. package/automations/catalog/gitlab-issue-to-mr/manifest.json +124 -0
  15. package/automations/catalog/qa-changes/manifest.json +1 -1
  16. package/automations/catalog-index.js +23 -17
  17. package/automations/catalog.schema.json +81 -7
  18. package/automations/index.d.ts +52 -2
  19. package/automations/interface.json +2 -1
  20. package/integrations/catalog/gitlab.json +27 -5
  21. package/integrations/catalog/reportportal.json +76 -0
  22. package/integrations/catalog-index.js +6 -4
  23. package/marketplaces/openhands-extensions.json +36 -0
  24. package/package.json +1 -1
  25. package/pyproject.toml +2 -2
  26. package/python/openhands_extensions/_version.py +1 -1
  27. package/skills/github/scripts/github_client.py +204 -0
  28. package/skills/github-agents-md-maintainer/SKILL.md +7 -1
  29. package/skills/github-agents-md-maintainer/scripts/main.py +4 -40
  30. package/skills/github-issue-to-pr/SKILL.md +8 -1
  31. package/skills/github-issue-to-pr/scripts/main.py +3 -40
  32. package/skills/github-issue-triage/.plugin/plugin.json +9 -0
  33. package/skills/github-issue-triage/README.md +9 -0
  34. package/skills/github-issue-triage/SKILL.md +37 -0
  35. package/skills/github-issue-triage/commands/github-issue-triage.md +8 -0
  36. package/skills/github-issue-triage/scripts/worker.py +123 -0
  37. package/skills/github-pr-reviewer/SKILL.md +8 -1
  38. package/skills/github-pr-reviewer/scripts/main.py +3 -41
  39. package/skills/github-repo-monitor/SKILL.md +7 -1
  40. package/skills/github-repo-monitor/scripts/main.py +4 -46
  41. package/skills/gitlab-issue-to-mr/.plugin/plugin.json +19 -0
  42. package/skills/gitlab-issue-to-mr/README.md +92 -0
  43. package/skills/gitlab-issue-to-mr/SKILL.md +408 -0
  44. package/skills/gitlab-issue-to-mr/commands/issue-to-mr-setup.md +8 -0
  45. package/skills/gitlab-issue-to-mr/references/state-schema.md +140 -0
  46. package/skills/gitlab-issue-to-mr/scripts/main.py +1448 -0
  47. package/skills/index.js +43 -6
  48. package/skills/openhands-automation/README.md +1 -1
  49. package/skills/openhands-automation/SKILL.md +18 -8
  50. package/skills/openhands-enterprise-troubleshooting/.plugin/plugin.json +20 -0
  51. package/skills/openhands-enterprise-troubleshooting/README.md +79 -0
  52. package/skills/openhands-enterprise-troubleshooting/SKILL.md +339 -0
  53. package/skills/openhands-enterprise-troubleshooting/references/diagnostics.md +698 -0
  54. package/skills/openhands-enterprise-troubleshooting/references/support-bundle-analysis.md +570 -0
  55. package/skills/openhands-enterprise-troubleshooting/scripts/bundle_triage.py +532 -0
  56. package/skills/openhands-sdk/SKILL.md +2 -0
  57. package/tests/fixtures/automations/custom-automation.json +398 -0
  58. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  59. package/tests/github_automation_helpers.py +25 -0
  60. package/tests/test_automation_setup.py +340 -92
  61. package/tests/test_github_automation_foundation.py +73 -0
  62. package/tests/test_github_skill_installation.py +63 -0
  63. package/tests/test_github_triage_delivery.py +53 -0
  64. package/tests/test_gitlab_issue_to_mr.py +823 -0
@@ -0,0 +1,698 @@
1
+ # OHE Diagnostics Reference
2
+
3
+ Detailed diagnostic procedures for each OpenHands Enterprise failure mode. Run these commands on the
4
+ VM via SSH.
5
+
6
+ **`kubectl` is not on the PATH by default**, so nothing below works until you get a working client.
7
+ `sudo ./openhands shell` exports the kubeconfig and puts `kubectl` on your PATH — but it needs a TTY,
8
+ so it will not work from a non-interactive session or an agent without one. In that case use the
9
+ bundled k0s client instead, which needs no TTY:
10
+
11
+ ```bash
12
+ sudo k0s kubectl get pods -A
13
+ ```
14
+
15
+ Note that `kubectl support-bundle` resolves only inside `openhands shell`; the plugin is not on the
16
+ PATH for `sudo k0s kubectl`.
17
+
18
+ **Working from a support bundle instead of a live cluster?** Every command here has an offline
19
+ equivalent. See [`support-bundle-analysis.md`](support-bundle-analysis.md) for the full mapping; the
20
+ short version:
21
+
22
+ | Live command | In the bundle |
23
+ |---|---|
24
+ | `kubectl get pods -n <ns>` | `cluster-resources/pods/<ns>.json` |
25
+ | `kubectl logs <pod> -c <container>` | `cluster-resources/pods/logs/<ns>/<pod>/<container>.log` |
26
+ | `kubectl describe pod` | Reconstruct from `cluster-resources/pods/<ns>.json` (no `describe` is stored) |
27
+ | `kubectl get events -n <ns>` | `cluster-resources/events/<ns>.json` (short TTL window) |
28
+ | `kubectl top pods` / `top nodes` | `node-metrics/<node>.json` |
29
+ | `kubectl get ingress -n <ns>` | `cluster-resources/ingress/<ns>.json` |
30
+ | `kubectl get configmap/secret` | `cluster-resources/configmaps/<ns>.json`, `secrets/<ns>/` (keys only) |
31
+
32
+ Start with `python3 scripts/bundle_triage.py <bundle>` and `analysis.json` before hand-writing `jq`.
33
+
34
+ ## Sandbox Startup
35
+
36
+ Sandboxes are created on demand by the **`runtime-api`** service; each one is its own
37
+ `runtime-*` pod. There is no long-lived "sandbox" deployment, so start with `runtime-api` when
38
+ sandboxes fail to start at all, and with the individual `runtime-*` pod when one specific
39
+ conversation fails.
40
+
41
+ ```bash
42
+ # The service that creates sandboxes
43
+ kubectl get pods -n openhands -l app.kubernetes.io/name=runtime-api
44
+ kubectl logs -n openhands -l app.kubernetes.io/name=runtime-api --tail=100
45
+
46
+ # The sandboxes themselves
47
+ kubectl get pods -n openhands | grep '^runtime-'
48
+ ```
49
+
50
+ Look for: `Running` status, multiple restarts, `ImagePullBackOff`, `CrashLoopBackOff`
51
+
52
+ Zero `runtime-` pods is not itself a fault. These are per-conversation sandboxes plus any warm pool,
53
+ so an idle install with the pool at zero legitimately has none, and a pod that is present may be a
54
+ warm spare rather than anyone's session. Judge sandbox startup by whether a *new* conversation gets
55
+ one, and by `runtime-api` logs — not by the count here.
56
+
57
+ ### Check Sandbox Logs
58
+
59
+ ```bash
60
+ # A specific sandbox pod
61
+ kubectl logs -n openhands <runtime-pod> --tail=100
62
+
63
+ # Previous log, if it restarted
64
+ kubectl logs -n openhands <runtime-pod> --previous
65
+ ```
66
+
67
+ ### Common Sandbox Startup Errors
68
+
69
+ | Error Pattern | Likely Cause | Check |
70
+ |---------------|--------------|-------|
71
+ | `ImagePullBackOff` | Registry auth, network | `kubectl describe pod` for image pull error |
72
+ | `CrashLoopBackOff` | Config error, missing secret | `kubectl logs --previous` |
73
+ | `Init:Error` | Init container failed | `kubectl describe pod` for init container status |
74
+ | `Timeout` | Resource exhaustion, runtime issue | `kubectl top pods` |
75
+
76
+ ### Sandbox Runtime Check
77
+
78
+ Sandboxes run under the **`sysbox-runc`** RuntimeClass, which maps to a sysbox containerd runtime
79
+ registered on each node by a DaemonSet. If that registration failed, every sandbox stays Pending or
80
+ fails to create while the rest of the platform looks healthy.
81
+
82
+ ```bash
83
+ # The RuntimeClass sandboxes depend on
84
+ kubectl get runtimeclass sysbox-runc
85
+
86
+ # The installer that registers it on each node
87
+ kubectl get pods -A -l app.kubernetes.io/name=sysbox-installer
88
+
89
+ # Disk space inside a running sandbox
90
+ kubectl exec -n openhands <runtime-pod> -- df -h
91
+ ```
92
+
93
+ A missing runtime is not the only reason a sandbox stays Pending. If `runtime-api` is configured
94
+ with a `RUNTIME_NODE_SELECTOR`, sandboxes can only land on nodes carrying the labels it names, and
95
+ capacity on any other node is unreachable. The scheduler says which it was:
96
+
97
+ ```bash
98
+ # The scheduler's own reason — names the selector, the affinity, or the missing resource
99
+ kubectl describe pod -n openhands <runtime-pod> | grep -A5 Events
100
+
101
+ # What the nodes actually offer
102
+ kubectl get nodes --show-labels
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Git Provider Auth
108
+
109
+ Four providers are supported, each configured independently: **GitHub**, **GitLab**,
110
+ **Bitbucket Data Center**, and **Azure DevOps**. There is no `git-provider-secret` and no
111
+ per-provider pod — each provider is one Secret, consumed as environment variables by the main
112
+ `openhands` deployment. So provider auth failures show up in the app's own logs, not in a
113
+ dedicated workload.
114
+
115
+ | Provider | Secret | Keys |
116
+ |---|---|---|
117
+ | GitHub | `github-app` | `app-id`, `app-slug`, `client-id`, `client-secret`, `private-key`, `webhook-secret` |
118
+ | GitLab | `gitlab-app` | `client-id`, `client-secret` |
119
+ | Bitbucket Data Center | `bitbucket-data-center-app` | `host`, `client-id`, `client-secret`, `bot-token` |
120
+ | Azure DevOps | `azure-devops-app` | `client-id`, `client-secret`, `webhook-secret` |
121
+
122
+ ### Which providers are configured
123
+
124
+ ```bash
125
+ # Only configured providers have a secret -- absence is the usual "auth broken" cause
126
+ kubectl get secret -n openhands github-app gitlab-app bitbucket-data-center-app azure-devops-app 2>&1
127
+
128
+ # Confirm the app was actually told to enable it (secret present but disabled is a common trap).
129
+ # Provider variables are not all bare-prefixed — expect OPENHANDS_*_SERVICE_CLS,
130
+ # OH_WEB_CLIENT_PROVIDERS_CONFIGURED and *_APP_CLIENT_* alongside GITHUB_/GITLAB_.
131
+ kubectl set env deploy/openhands -n openhands --list \
132
+ | grep -iE 'github|gitlab|bitbucket|azure|providers_configured'
133
+ ```
134
+
135
+ `OH_WEB_CLIENT_PROVIDERS_CONFIGURED` is the most direct answer to "which provider does the app think
136
+ it has": it lists them.
137
+
138
+ ### Check a provider secret has the keys it needs
139
+
140
+ Drive this off whichever secret actually exists rather than assuming GitHub — most installs have one
141
+ provider, and the other three will be `NotFound`. This prints key names and byte lengths, never the
142
+ values:
143
+
144
+ ```bash
145
+ for s in github-app gitlab-app bitbucket-data-center-app azure-devops-app; do
146
+ kubectl get secret -n openhands "$s" >/dev/null 2>&1 || continue
147
+ echo "== $s"
148
+ kubectl get secret -n openhands "$s" \
149
+ -o go-template='{{range $k,$v := .data}}{{$k}}={{len $v}} bytes{{"\n"}}{{end}}'
150
+ done
151
+ ```
152
+
153
+ Compare the keys against the row for that provider in the table above.
154
+
155
+ A key present but zero-length is the failure worth looking for — Helm renders empty values into a
156
+ valid Secret, so the object exists and looks correct while auth fails.
157
+
158
+ ### Validate credentials against the provider
159
+
160
+ Each provider uses a different auth scheme and endpoint. Bitbucket Data Center and Azure DevOps are
161
+ self-hosted, so the host comes from your config, not a fixed domain.
162
+
163
+ ```bash
164
+ # GitHub App -- requires a signed JWT, so a plain token check is not meaningful.
165
+ # Verify the app can see its installations (run from a pod with the credentials):
166
+ curl -s -H "Authorization: Bearer $GITHUB_JWT" https://api.github.com/app/installations
167
+
168
+ # GitLab
169
+ curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" "https://gitlab.com/api/v4/user"
170
+
171
+ # Bitbucket Data Center -- self-hosted, REST API 1.0, bot token
172
+ curl -s -H "Authorization: Bearer $BITBUCKET_DATA_CENTER_BOT_TOKEN" \
173
+ "https://$BITBUCKET_DATA_CENTER_HOST/rest/api/1.0/users"
174
+
175
+ # Azure DevOps -- PAT, basic auth with an empty username
176
+ curl -s -u ":$AZURE_DEVOPS_TOKEN" \
177
+ "https://dev.azure.com/$AZURE_DEVOPS_ORG/_apis/projects?api-version=7.0"
178
+ ```
179
+
180
+ For a self-hosted provider, an auth failure is often really a **network or TLS** failure: the
181
+ cluster may not be able to reach the Bitbucket or Azure DevOps host at all, or may reject its
182
+ certificate. Test reachability from inside a pod before assuming the credentials are wrong, and see
183
+ the Certificate Issues section below for private CAs.
184
+
185
+ ---
186
+
187
+ ## Certificate Issues
188
+
189
+ ### Check Certificate Expiry
190
+
191
+ Read the port off the ingress controller Service. Do not go looking for a listening socket: a
192
+ NodePort is served by kube-proxy DNAT rules, so `ss -lntp` shows nothing even though the port works.
193
+
194
+ ```bash
195
+ kubectl get svc -A | grep -iE 'traefik|ingress-nginx' # e.g. 80:80/TCP,443:443/TCP
196
+
197
+ # HOST must be a bare hostname, not a URL — a leading https:// makes the
198
+ # hostname check below fail against a perfectly good certificate.
199
+ HOST="your-openhands-domain.com"
200
+
201
+ # The public name generally does not resolve from the VM itself, so connect to
202
+ # the local listener and pass the name as SNI.
203
+ echo | openssl s_client -connect 127.0.0.1:443 -servername $HOST 2>/dev/null \
204
+ | openssl x509 -noout -dates
205
+
206
+ # s_client does NOT check the hostname unless you ask it to: a certificate for
207
+ # the wrong name still reports "Verify return code: 0 (ok)". Add -verify_hostname
208
+ # to actually test the name, or a mismatch will read as healthy.
209
+ # Expect 0 (ok) for the right name, 62 (hostname mismatch) for the wrong one.
210
+ echo | openssl s_client -connect 127.0.0.1:443 -servername $HOST \
211
+ -verify_hostname $HOST 2>/dev/null | grep -E "Verify return code"
212
+
213
+ # Find the TLS secret the ingress actually references, then read its certificate.
214
+ # There is no `app=ingress-tls` label — the secret is named in the ingress spec.
215
+ kubectl get ingress -n openhands \
216
+ -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.tls[*].secretName}{"\n"}{end}'
217
+
218
+ kubectl get secret -n openhands <tls-secret> -o jsonpath='{.data.tls\.crt}' \
219
+ | base64 -d | openssl x509 -noout -dates -subject -issuer
220
+ ```
221
+
222
+ A blank `secretName` against a `runtime-<id>` ingress is normal, not a fault: sandbox routing is
223
+ HTTP-only and TLS for it is handled by the ingress controller's default certificate.
224
+
225
+ ### Check Certificate Chain
226
+
227
+ From outside the VM, use `-connect $HOST:443` instead.
228
+
229
+ ```bash
230
+ # Get full certificate chain
231
+ echo | openssl s_client -connect 127.0.0.1:443 -servername $HOST -showcerts 2>/dev/null
232
+
233
+ # Check chain completeness
234
+ echo | openssl s_client -connect 127.0.0.1:443 -servername $HOST 2>/dev/null | grep -A2 "Certificate chain"
235
+ ```
236
+
237
+ ### Common Certificate Errors
238
+
239
+ | Error | Cause | Fix |
240
+ |-------|-------|-----|
241
+ | `CERT_HAS_EXPIRED` | Certificate expired | Renew certificate |
242
+ | `self signed certificate` | Self-signed in chain | Install proper chain |
243
+ | `UNABLE_TO_VERIFY_LEAF_SIGNATURE` | Intermediate missing | Ensure full chain in ingress |
244
+ | `hostname mismatch` (openssl, verify code 62); `no alternative certificate subject name matches target host name` (curl) | Wrong CN/SAN | Reissue with correct hostname |
245
+
246
+ ### Ingress TLS Check
247
+
248
+ ```bash
249
+ kubectl get ingress -n openhands -o yaml | grep -A5 "tls:"
250
+ ```
251
+
252
+ ---
253
+
254
+ ## LLM Connectivity
255
+
256
+ ### Check LLM Configuration
257
+
258
+ LLM wiring lives in the LiteLLM config map and the env secrets, not in a `llm-config` /
259
+ `llm-credentials` pair:
260
+
261
+ ```bash
262
+ # Model routing: which models are defined and where they point
263
+ kubectl get configmap -n openhands openhands-litellm-config \
264
+ -o jsonpath='{.data.config\.yaml}'
265
+
266
+ # Credential wiring — key names only, never values
267
+ kubectl get secret -n openhands litellm-env-secrets \
268
+ -o go-template='{{range $k,$v := .data}}{{$k}}={{len $v}} bytes{{"\n"}}{{end}}'
269
+ kubectl get secret -n openhands openhands-env-secrets \
270
+ -o go-template='{{range $k,$v := .data}}{{$k}}={{len $v}} bytes{{"\n"}}{{end}}'
271
+ ```
272
+
273
+ Expect keys along the lines of `LLM_API_KEY`, `LLM_BASE_URL`, `LLM_MODEL`. A key that is present but
274
+ zero bytes is the failure worth finding: Helm renders an empty value into a valid Secret, so the
275
+ object looks correct while every request fails to authenticate.
276
+
277
+ ### Test LLM Endpoint
278
+
279
+ > **Do not decode the API key into your shell.** It is a live credential, and on a real install that
280
+ > puts it into your terminal history and any agent transcript. Run the request *inside* the pod that
281
+ > already holds the key, so the value never leaves the container.
282
+
283
+ **The secret key names are not the pod's variable names.** The keys above are what the Secret
284
+ contains; the app pod exposes the endpoint as `LITE_LLM_API_URL` and `LITE_LLM_API_KEY`. Always list
285
+ what the pod actually has before building a request against it:
286
+
287
+ ```bash
288
+ kubectl exec -n openhands deploy/openhands -- printenv \
289
+ | grep -iE 'LLM|LITE_LLM' | sed 's/=.*/=<set>/'
290
+ ```
291
+
292
+ Then use the names it reports:
293
+
294
+ ```bash
295
+ kubectl exec -n openhands deploy/openhands -- sh -c '
296
+ curl -s -o /dev/null -w "HTTP_CODE:%{http_code}\n" \
297
+ -H "Authorization: Bearer $LITE_LLM_API_KEY" \
298
+ "$LITE_LLM_API_URL/v1/models"'
299
+ ```
300
+
301
+ A 200 confirms the credential and the network path. A 401 or 403 means the credential is wrong; a
302
+ timeout or `connection refused` means the endpoint is unreachable from the cluster, which is a
303
+ network problem rather than a credential one. An empty variable in the request — producing a URL like
304
+ `/v1/models` with no host — means you used a secret key name rather than the pod's variable name.
305
+ If `curl` is absent from the image, read the failure out of the application log instead: it reports
306
+ the upstream status on every failed call.
307
+
308
+ ### Network Policy Check
309
+
310
+ ```bash
311
+ # Check if pods have network policies
312
+ kubectl get networkpolicy -n openhands
313
+
314
+ # Test DNS resolution from pod. The app image ships no `nslookup` or `dig`,
315
+ # so use getent, which is part of libc and always present.
316
+ kubectl exec -n openhands deploy/openhands -- getent hosts api.openai.com
317
+ ```
318
+
319
+ No output from `getent` means the name did not resolve. If you need to prove the whole path rather
320
+ than just DNS, `curl -sS -o /dev/null -w '%{http_code}\n' https://api.openai.com` distinguishes a
321
+ resolution failure from a routing or TLS one by the error it reports.
322
+
323
+ ### Common LLM Errors
324
+
325
+ | Error Pattern | Cause | Fix |
326
+ |---------------|-------|-----|
327
+ | `connection refused` | Wrong endpoint | Verify LLM endpoint URL |
328
+ | `401 Unauthorized` | Bad API key | Re-create/rotate API key |
329
+ | `403 Forbidden` | Insufficient permissions | Check model access |
330
+ | `timed out`, `Connection timed out`, `context deadline exceeded` | Network policy/firewall | Check network policies |
331
+
332
+ ---
333
+
334
+ ## Keycloak
335
+
336
+ Keycloak runs **in the `openhands` namespace**, as a StatefulSet rather than a Deployment — so the
337
+ pod is `keycloak-0` and the selector is `app.kubernetes.io/name=keycloak`. There is no `keycloak`
338
+ namespace and no `app=keycloak` label. Addressing it as a Deployment, or in its own namespace, is
339
+ the most common way these commands silently return nothing.
340
+
341
+ ### Check Keycloak Pods
342
+
343
+ **Start with the StatefulSet, not the pod list.** A pod query returns `No resources found` and exits
344
+ 0 whether Keycloak is scaled to zero or your selector is simply wrong — the two are byte-identical,
345
+ and you cannot tell a dead component from a bad query. The workload object always exists, so its
346
+ READY count answers the question directly: `0/0` is scaled down, `0/1` is failing to start, `1/1` is
347
+ up.
348
+
349
+ ```bash
350
+ kubectl get statefulset -n openhands keycloak
351
+ ```
352
+
353
+ Then, once READY tells you what you are looking at:
354
+
355
+ ```bash
356
+ kubectl get pods -n openhands -l app.kubernetes.io/name=keycloak
357
+ kubectl logs -n openhands keycloak-0 --tail=200
358
+ ```
359
+
360
+ ### Check Keycloak Database Connectivity
361
+
362
+ Bitnami images wire the database through `KEYCLOAK_DATABASE_*` rather than upstream's `KC_DB_*`, so
363
+ grepping only for `KC_DB` comes back empty on these installs and looks like nothing is configured.
364
+ Match both:
365
+
366
+ ```bash
367
+ # Ask the pod what database it is configured against, rather than assuming
368
+ kubectl exec -n openhands keycloak-0 -- printenv \
369
+ | grep -iE 'KC_DB|KEYCLOAK_DATABASE|DB_ADDR|DB_URL|JDBC|DATABASE' | sort
370
+
371
+ # The database error itself is usually in the log, and needs no in-pod tooling
372
+ kubectl logs -n openhands keycloak-0 --tail=200 \
373
+ | grep -iE 'connection refused|unknown host|timeout|FATAL|could not connect'
374
+ ```
375
+
376
+ ### Check Keycloak Realm Configuration
377
+
378
+ > **Do not decode the admin credential into your shell.** On a live install that writes a working
379
+ > password into your terminal history and into any agent transcript.
380
+
381
+ The admin credential is wired differently across versions, so check rather than assume. Some installs
382
+ carry a `keycloak-admin` secret mounted via `KC_BOOTSTRAP_ADMIN_PASSWORD_FILE`; others expose only a
383
+ `keycloak-realm` secret and no `-admin` secret at all. Read the wiring, never the value:
384
+
385
+ ```bash
386
+ # Which keycloak secrets exist here? Names only.
387
+ kubectl get secret -n openhands -o name | grep -i keycloak
388
+
389
+ # Which bootstrap mechanism is in use? Values masked.
390
+ kubectl exec -n openhands keycloak-0 -- printenv | grep -i 'KC_BOOTSTRAP_ADMIN' | sed 's/=.*/=<set>/'
391
+ ```
392
+
393
+ To confirm Keycloak is serving its realm, use the public endpoint, which needs no credential:
394
+
395
+ ```bash
396
+ # Select by the host it serves rather than by resource name — the ingress is
397
+ # named `keycloak` on current builds, but the auth host is the stable property.
398
+ KEYCLOAK_HOST=$(kubectl get ingress -n openhands \
399
+ -o jsonpath='{range .items[*].spec.rules[*]}{.host}{"\n"}{end}' | grep -i '^auth\.' | head -1)
400
+
401
+ # The scheme matters — without it curl defaults to http:// and a TLS-fronted
402
+ # Keycloak just redirects.
403
+ curl -fsS -o /dev/null -w '%{http_code}\n' \
404
+ "https://$KEYCLOAK_HOST/realms/master/.well-known/openid-configuration"
405
+ ```
406
+
407
+ ### Keycloak Health Check
408
+
409
+ The realm check above is the one that answers the question that actually matters — whether users can
410
+ reach Keycloak — because it goes through the ingress and so exercises DNS, TLS, and routing. Prefer
411
+ it, and read a failure there as a fault in the path rather than in the server.
412
+
413
+ **Do not rely on `/health` here.** These installs ship Bitnami's Keycloak image, which does not
414
+ expose the management interface that upstream Keycloak 25+ serves on port 9000. On a verified
415
+ v0.58.0 install running Bitnami Keycloak 26.3.0, only `8080` and `7800` are exposed, `:9000` refuses
416
+ the connection outright, `:8080/health*` returns 404, and the realm endpoint is the only thing that
417
+ answers 200. A health check against this pod tells you nothing about Keycloak's state.
418
+
419
+ Confirm what the pod actually exposes before assuming any health port exists:
420
+
421
+ ```bash
422
+ kubectl get pod -n openhands keycloak-0 \
423
+ -o jsonpath='{range .spec.containers[*].ports[*]}{.name}:{.containerPort}{"\n"}{end}'
424
+ kubectl get pod -n openhands keycloak-0 -o jsonpath='{.spec.containers[*].image}{"\n"}'
425
+ ```
426
+
427
+ If the image is `bitnami/keycloak` or `bitnamilegacy/keycloak`, use the realm check above as the
428
+ liveness signal and stop there. Only on an upstream `quay.io/keycloak/keycloak` image at 25 or newer
429
+ is `:9000/health/ready` meaningful, and even then a 404 means health is disabled on the server —
430
+ `health-enabled` is off, or `--legacy-observability-interface=true` has kept the endpoints on the
431
+ main port — rather than Keycloak being unhealthy.
432
+
433
+ ---
434
+
435
+ ## Replicated Admin Console
436
+
437
+ The Admin Console (KOTS) runs in the `kotsadm` namespace. The Embedded Cluster operator runs in
438
+ `embedded-cluster`. Start a cluster shell first — it exports the kubeconfig and puts `kubectl` on
439
+ your PATH:
440
+
441
+ ```bash
442
+ sudo ./openhands shell
443
+ ```
444
+
445
+ ### Check Admin Console Pods
446
+
447
+ The admin console chart uses bare `app=` labels — `app=kotsadm`,
448
+ `app=kotsadm-rqlite`, `app=kurl-proxy-kotsadm` — unlike the rest of the platform. Its
449
+ `app.kubernetes.io/name` is `admin-console`, so "correcting" these to
450
+ `app.kubernetes.io/name=kotsadm` breaks them.
451
+
452
+ ```bash
453
+ kubectl get pods -n kotsadm
454
+
455
+ # Admin console logs
456
+ kubectl logs -n kotsadm -l app=kotsadm --tail=100
457
+
458
+ # rqlite is the admin console's datastore; kotsadm will not start without it
459
+ kubectl get pods -n kotsadm -l app=kotsadm-rqlite
460
+ ```
461
+
462
+ ### Check Embedded Cluster Operator
463
+
464
+ ```bash
465
+ kubectl get pods -n embedded-cluster
466
+ kubectl logs -n embedded-cluster -l app.kubernetes.io/name=embedded-cluster-operator --tail=100
467
+ ```
468
+
469
+ ### Check Services and Ingress
470
+
471
+ ```bash
472
+ # The console is served by kurl-proxy-kotsadm, a NodePort on 8800:30000. The
473
+ # name is a kURL leftover but the service is still there on Embedded Cluster.
474
+ kubectl get svc -n kotsadm -o wide
475
+
476
+ # From the VM itself, bypassing any external networking
477
+ curl -sk -o /dev/null -w "%{http_code}\n" https://localhost:30000
478
+ ```
479
+
480
+ ### Common Admin Console Issues
481
+
482
+ | Symptom | Check | Fix |
483
+ |---------|-------|-----|
484
+ | "Connection refused" on admin console | `kubectl get pods -n kotsadm` | Restart the kotsadm pod |
485
+ | Reachable on localhost:30000 but not externally | Host firewall / security group | Open port 30000 to the client |
486
+ | Admin console shows blank page | kotsadm logs | Check for migration errors |
487
+ | kotsadm stuck in `Init` | rqlite pod status | Fix rqlite first — kotsadm waits on it |
488
+
489
+ ### Cluster Status from the Host
490
+
491
+ ```bash
492
+ # Version and install state
493
+ sudo ./openhands version
494
+
495
+ # The embedded cluster runs on k0s; check the host service if the API is unreachable
496
+ sudo systemctl status k0scontroller
497
+ sudo journalctl -u k0scontroller --since "1 hour ago" --no-pager | tail -50
498
+ ```
499
+
500
+ > The `replicated` CLI is a **vendor-side** tool and is not present on a customer VM. Use the
501
+ > application binary (`sudo ./openhands …`) and `kubectl` from within `sudo ./openhands shell`.
502
+
503
+ ---
504
+
505
+ ## Upgrade Issues
506
+
507
+ > **Upgrades and rollbacks belong in the Admin Console.** KOTS owns the deployment state, so the
508
+ > Admin Console is the only place that can change it safely. Everything in this section is read-only
509
+ > diagnosis for an upgrade that has already gone wrong: use it to work out *what* failed, then act
510
+ > in the Admin Console. Confirm what you find there, and escalate if the two disagree.
511
+
512
+ ### Check Failed Upgrade Jobs
513
+
514
+ ```bash
515
+ kubectl get jobs -n openhands | grep -E "upgrade|migrate"
516
+
517
+ # Check failed job logs
518
+ UPGRADE_JOB=$(kubectl get jobs -n openhands -o jsonpath='{.items[?(@.status.failed)].metadata.name}' | awk '{print $1}')
519
+ kubectl logs -n openhands job/$UPGRADE_JOB
520
+ ```
521
+
522
+ ### Check Pre-flight Status
523
+
524
+ Preflight results for the current version are shown in the Admin Console under the version history
525
+ entry. From the CLI:
526
+
527
+ ```bash
528
+ # Preflight state for the deployed version. Preflight runs once at deploy, so on
529
+ # anything but a just-installed cluster those lines have scrolled out of a short
530
+ # tail — search the whole log, not the last N lines.
531
+ kubectl get pods -n kotsadm -l app=kotsadm -o name \
532
+ | head -1 | xargs -I{} kubectl logs -n kotsadm {} | grep -i preflight
533
+ ```
534
+
535
+ Silence here means the log no longer reaches back to the deploy, not that preflight passed. The
536
+ Admin Console version history is the reliable source; treat the log as a shortcut that expires.
537
+
538
+ The last preflight run also leaves a full cluster snapshot inside any support bundle at
539
+ `kots/admin_console/kotsadm/*/kotsadm/tmp/last-preflight-result/` — useful as a second point in time
540
+ to diff against. See `support-bundle-analysis.md`.
541
+
542
+ ### Rollback
543
+
544
+ Rollback is only possible if the application enables it (`allowRollback` in the KOTS Application
545
+ spec). It is driven from the **Admin Console** version history:
546
+
547
+ 1. Open the Admin Console (`https://<vm-host>:30000`) → **Version history**
548
+ 2. Find the previously deployed version
549
+ 3. Click **Deploy** on that version
550
+
551
+ > If the install is broken, collect a support bundle and escalate. The Admin Console owns the
552
+ > deployment state, so restoring a working version is a job for the Console or for the OpenHands
553
+ > team.
554
+
555
+ ### Common Upgrade Failures
556
+
557
+ | Error | Cause | Fix |
558
+ |-------|-------|-----|
559
+ | Migration job failed | Database schema change | Check job logs, retry |
560
+ | Pods crash on new version | Config incompatibility | Review changelog, adjust config |
561
+ | Pre-flight failed | Resource insufficient | Add resources, retry |
562
+ | Helm error | Values incompatible | Review helm values diff |
563
+
564
+ ---
565
+
566
+ ## Resource Exhaustion
567
+
568
+ ### Check Node Resources
569
+
570
+ ```bash
571
+ # Node CPU/memory
572
+ kubectl top nodes
573
+
574
+ # Node disk usage. This is a single-node install, so `df -h /` in an SSH session
575
+ # on the VM answers the same question without a debug pod. `kubectl debug` also
576
+ # needs a TTY, which an agent shell may not have; the node filesystem appears
577
+ # under /host inside it.
578
+ df -h /
579
+
580
+ # Check if OOMKilled
581
+ kubectl get events -n openhands | grep -i "oom\|killed"
582
+ ```
583
+
584
+ ### Check Pod Resource Usage
585
+
586
+ ```bash
587
+ # Per-pod resource usage
588
+ kubectl top pods -n openhands
589
+
590
+ # Check pod resource limits
591
+ kubectl get pods -n openhands -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[*].resources.limits.memory}{"\n"}'
592
+ ```
593
+
594
+ ### Check File Descriptor Usage
595
+
596
+ Ask the **server process** (PID 1 in the container), not your own shell and not the `ls` you just
597
+ spawned. `ulimit -n` reports the limit of whatever shell you are sitting in, `/proc/sys/fs/file-max`
598
+ is a host-wide ceiling, and `ls /proc/self/fd` counts the handles of `ls` itself — all three return
599
+ a confident, plausible number that says nothing about the app:
600
+
601
+ ```bash
602
+ # The app's actual fd limit and current usage
603
+ kubectl exec -n openhands deploy/openhands -- sh -c \
604
+ 'grep -i "open files" /proc/1/limits; ls /proc/1/fd | wc -l'
605
+ ```
606
+
607
+ Compare the count against the limit. If they are not close, fd exhaustion is not your fault — look
608
+ elsewhere before raising limits.
609
+
610
+ ### Check Disk Space
611
+
612
+ ```bash
613
+ # Node disk pressure
614
+ kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.conditions[?(@.type=="DiskPressure")].status}{"\n"}'
615
+
616
+ # Find large directories. The container runs as non-root, so du reports
617
+ # "Permission denied" on paths it cannot read and exits 1 even when it
618
+ # succeeded elsewhere — read the sizes, not the exit code.
619
+ kubectl exec -n openhands deploy/openhands -- du -sh /var/* 2>/dev/null
620
+ ```
621
+
622
+ ### Common Resource Exhaustion Fixes
623
+
624
+ | Resource | Check | Fix |
625
+ |----------|-------|-----|
626
+ | Memory OOM | `kubectl top pods` | Increase pod memory limits |
627
+ | Disk full | `du -sh` | Clean up logs, increase PV size |
628
+ | FD exhaustion | `/proc/1/limits` vs `ls /proc/1/fd \| wc -l` in the pod | Increase the container's limit |
629
+ | CPU throttling | `kubectl top pods` | Adjust CPU limits |
630
+
631
+ ---
632
+
633
+ ## Log Pattern Quick Reference
634
+
635
+ ### Search for Common Error Patterns
636
+
637
+ App logs are structured JSON with a `severity` key, and the app is Python/uvicorn — whose top tier is
638
+ `CRITICAL`, not `FATAL`, so grepping for `FATAL` never matches. Filter on the field:
639
+
640
+ ```bash
641
+ kubectl logs -n openhands deploy/openhands --tail=2000 \
642
+ | grep -E '"severity": ?"(ERROR|CRITICAL)"'
643
+ ```
644
+
645
+ If that returns nothing on an install you know has errors, print one line first and match the field
646
+ as it is really formatted — quoting and spacing vary by log library:
647
+
648
+ ```bash
649
+ kubectl logs -n openhands deploy/openhands --tail=1
650
+ ```
651
+
652
+ ```bash
653
+ # In pod logs, search for these patterns:
654
+ grep -E "ERROR|CRITICAL|Exception|Traceback" /path/to/logs
655
+
656
+ # Search for timeout patterns
657
+ grep -E "timeout|timed out|deadline" /path/to/logs
658
+
659
+ # Search for connection errors
660
+ grep -E "connection refused|connection reset|dial tcp" /path/to/logs
661
+
662
+ # Search for auth errors
663
+ grep -E "unauthorized|forbidden|authentication" /path/to/logs
664
+ ```
665
+
666
+ ### Kubernetes Events
667
+
668
+ ```bash
669
+ # Get recent events in namespace
670
+ kubectl get events -n openhands --sort-by='.lastTimestamp' | tail -50
671
+
672
+ # Filter events by type
673
+ kubectl get events -n openhands --field-selector type=Warning
674
+ ```
675
+
676
+ ---
677
+
678
+ ## Useful One-Liners
679
+
680
+ ```bash
681
+ # Get all pod statuses at once
682
+ kubectl get pods -n openhands -o wide
683
+
684
+ # Tail logs from all pods with a label
685
+ kubectl logs -n openhands -l app.kubernetes.io/name=runtime-api --tail=50 -f
686
+
687
+ # Get pod restart count
688
+ kubectl get pods -n openhands -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[*].restartCount}{"\n"}'
689
+
690
+ # Check pod age and status
691
+ kubectl get pods -n openhands -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.phase}{"\t"}{.metadata.creationTimestamp}{"\n"}'
692
+
693
+ # Extract error messages from all pods
694
+ for pod in $(kubectl get pods -n openhands -o name); do
695
+ echo "=== $pod ===";
696
+ kubectl logs -n openhands $pod --tail=20 2>&1 | grep -iE "error|fatal" | head -5;
697
+ done
698
+ ```