@openhands/extensions 0.20.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.
- package/.github/workflows/check-extensions.yml +4 -0
- package/.github/workflows/pr-artifacts.yml +294 -0
- package/.github/workflows/pr-description-check.yml +4 -2
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +27 -3
- package/README.md +5 -2
- package/automations/bundle-index.js +13 -3
- package/automations/catalog/github-agents-md-maintainer/manifest.json +2 -1
- package/automations/catalog/github-issue-to-pr/manifest.json +2 -1
- package/automations/catalog/github-issue-triage/manifest.json +72 -0
- package/automations/catalog/github-pr-reviewer/manifest.json +2 -1
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +124 -0
- package/automations/catalog-index.js +15 -11
- package/integrations/catalog/reportportal.json +76 -0
- package/integrations/catalog-index.js +6 -4
- package/marketplaces/openhands-extensions.json +36 -0
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/github/scripts/github_client.py +204 -0
- package/skills/github-agents-md-maintainer/SKILL.md +7 -1
- package/skills/github-agents-md-maintainer/scripts/main.py +4 -40
- package/skills/github-issue-to-pr/SKILL.md +8 -1
- package/skills/github-issue-to-pr/scripts/main.py +3 -40
- package/skills/github-issue-triage/.plugin/plugin.json +9 -0
- package/skills/github-issue-triage/README.md +9 -0
- package/skills/github-issue-triage/SKILL.md +37 -0
- package/skills/github-issue-triage/commands/github-issue-triage.md +8 -0
- package/skills/github-issue-triage/scripts/worker.py +123 -0
- package/skills/github-pr-reviewer/SKILL.md +8 -1
- package/skills/github-pr-reviewer/scripts/main.py +3 -41
- package/skills/github-repo-monitor/SKILL.md +7 -1
- package/skills/github-repo-monitor/scripts/main.py +4 -46
- package/skills/gitlab-issue-to-mr/.plugin/plugin.json +19 -0
- package/skills/gitlab-issue-to-mr/README.md +92 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +408 -0
- package/skills/gitlab-issue-to-mr/commands/issue-to-mr-setup.md +8 -0
- package/skills/gitlab-issue-to-mr/references/state-schema.md +140 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +1448 -0
- package/skills/index.js +43 -6
- package/skills/openhands-automation/README.md +1 -1
- package/skills/openhands-automation/SKILL.md +18 -8
- package/skills/openhands-enterprise-troubleshooting/.plugin/plugin.json +20 -0
- package/skills/openhands-enterprise-troubleshooting/README.md +79 -0
- package/skills/openhands-enterprise-troubleshooting/SKILL.md +339 -0
- package/skills/openhands-enterprise-troubleshooting/references/diagnostics.md +698 -0
- package/skills/openhands-enterprise-troubleshooting/references/support-bundle-analysis.md +570 -0
- package/skills/openhands-enterprise-troubleshooting/scripts/bundle_triage.py +532 -0
- package/skills/openhands-sdk/SKILL.md +2 -0
- package/tests/github_automation_helpers.py +25 -0
- package/tests/test_github_automation_foundation.py +73 -0
- package/tests/test_github_skill_installation.py +63 -0
- package/tests/test_github_triage_delivery.py +53 -0
- package/tests/test_gitlab_issue_to_mr.py +823 -0
- package/.pr/custom-automation-event-trigger.png +0 -0
- package/.pr/custom-automation-plugin-fields.png +0 -0
- package/.pr/custom-automation-prompt-review.png +0 -0
- package/.pr/custom-automation-prompt-scheduled.png +0 -0
- package/.pr/custom-automation-upload-fields.png +0 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
# Support Bundle Analysis
|
|
2
|
+
|
|
3
|
+
How to read a Replicated support bundle from an OpenHands Enterprise install, offline and without
|
|
4
|
+
cluster access. A bundle is a point-in-time snapshot collected by
|
|
5
|
+
[troubleshoot.sh](https://troubleshoot.sh) — it contains nearly everything you would get from
|
|
6
|
+
`kubectl` against the live cluster, but the data is reorganized, partly redacted, and has specific
|
|
7
|
+
gaps you must know about before you trust a negative result.
|
|
8
|
+
|
|
9
|
+
Use this after generating a bundle (see "Support Bundle Generation" in `SKILL.md`), or when someone
|
|
10
|
+
hands you a `support-bundle-*` directory.
|
|
11
|
+
|
|
12
|
+
## Unpack first
|
|
13
|
+
|
|
14
|
+
The download is a `.tar.gz` and nothing below works until it is extracted.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
tar -xzf support-bundle-2026-07-28T06_54_18.tar.gz
|
|
18
|
+
cd support-bundle-2026-07-28T06_54_18
|
|
19
|
+
|
|
20
|
+
# Sanity check: a real bundle has these at its root
|
|
21
|
+
ls analysis.json version.yaml cluster-resources/
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The archive expands to a single `support-bundle-<UTC timestamp>/` directory. Every path in this
|
|
25
|
+
document is relative to that directory. Note that macOS Safari may have auto-extracted it already,
|
|
26
|
+
in which case the directory is present and the archive is not.
|
|
27
|
+
|
|
28
|
+
## Establish when the bundle was taken
|
|
29
|
+
|
|
30
|
+
Every age, "X ago", and staleness judgment depends on this, and getting it wrong is the single most
|
|
31
|
+
common mistake.
|
|
32
|
+
|
|
33
|
+
**Do not use file mtimes.** They record when *you* extracted the archive, in *your* local timezone —
|
|
34
|
+
a silent multi-hour error in every age you compute.
|
|
35
|
+
|
|
36
|
+
Use, in order of preference:
|
|
37
|
+
|
|
38
|
+
1. The bundle directory name — `support-bundle-2026-07-28T06_54_18` is a UTC timestamp.
|
|
39
|
+
2. The newest timestamp inside `node-metrics/<node>.json`.
|
|
40
|
+
3. `.status.conditions[].lastTransitionTime` on a recently-updated object.
|
|
41
|
+
|
|
42
|
+
## Fast path: the triage script
|
|
43
|
+
|
|
44
|
+
`scripts/bundle_triage.py` reconstructs the whole standard first pass in one command. Standard
|
|
45
|
+
library only, no dependencies.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python3 scripts/bundle_triage.py /path/to/support-bundle-2026-07-28T06_54_18
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
It prints, in order: bundle metadata and node capacity, analyzer verdicts, a
|
|
52
|
+
`kubectl get pods -o wide` table, an OOM/restart scan across every namespace, a `kubectl top pods`
|
|
53
|
+
equivalent, allocated-vs-allocatable resources, and an events summary.
|
|
54
|
+
|
|
55
|
+
The script reports; it does not rank or diagnose. Reading the output for which of its observations
|
|
56
|
+
explains the symptom the user reported is the part that needs judgement, and it is yours to do. An
|
|
57
|
+
alarming-looking line can be long-standing and irrelevant, and the thing that actually broke may not
|
|
58
|
+
appear in the output at all (see below).
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Focus one namespace, or one section at a time
|
|
62
|
+
python3 scripts/bundle_triage.py <bundle> --namespace openhands
|
|
63
|
+
python3 scripts/bundle_triage.py <bundle> --section pods --section events
|
|
64
|
+
|
|
65
|
+
# Sandbox pods are collapsed by default; there can be hundreds
|
|
66
|
+
python3 scripts/bundle_triage.py <bundle> --expand-runtimes
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Use it to orient, then go to the raw files for anything it surfaces.
|
|
70
|
+
|
|
71
|
+
## Read these five first
|
|
72
|
+
|
|
73
|
+
| File | Gives you |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `analysis.json` | Pre-computed analyzer verdicts — often answers the question outright |
|
|
76
|
+
| `version.yaml` | Troubleshoot spec version |
|
|
77
|
+
| `cluster-info/cluster_version.json` | Kubernetes version |
|
|
78
|
+
| `cluster-resources/nodes.json` | Nodes, their labels and roles, capacity, conditions, taints |
|
|
79
|
+
| `kots/admin_console/app-info.json` | App status, channel, sequence, KOTS + embedded-cluster versions |
|
|
80
|
+
|
|
81
|
+
`analysis.json` is the fastest orientation in the bundle and the most frequently skipped. It ships
|
|
82
|
+
verdicts like `event.oom.check: "No OOMKilling event detected"` and
|
|
83
|
+
`node.resources.for.openhands: "Node resources are sufficient"` already computed.
|
|
84
|
+
|
|
85
|
+
Read it first, but do not stop there. Its analyzers are narrow — the OOM analyzer keys on *events*,
|
|
86
|
+
so it reports "No OOMKilling event detected" on a bundle whose pod objects clearly record
|
|
87
|
+
`OOMKilled` containers, simply because the events aged out of the TTL window. **A clean
|
|
88
|
+
`analysis.json` is not evidence of a clean cluster**, only that no analyzer's specific trigger
|
|
89
|
+
fired.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Failing analyzers, keeping severity and the distinct message
|
|
93
|
+
jq -r '.[] | select(.severity|test("error|warn|fail"))
|
|
94
|
+
| "\(.severity)\t\(.name)\t\(.insight.detail)"' analysis.json | sort
|
|
95
|
+
|
|
96
|
+
# Collapsing per-instance analyzers: replace only the identifier segments (long
|
|
97
|
+
# digit runs, hashes), never the whole middle -- wildcarding `a.b.*.status` merges
|
|
98
|
+
# unrelated subsystems and hides real failures behind an unrelated example.
|
|
99
|
+
jq -r '.[] | select(.severity|test("error|warn|fail")) | .name' analysis.json \
|
|
100
|
+
| sed -E 's/\.[0-9]{4,}\./.*./g' | sort | uniq -c | sort -rn
|
|
101
|
+
|
|
102
|
+
# Passing analyzers (what the collector already confirmed is fine)
|
|
103
|
+
jq -r '.[] | select(.severity=="debug") | "\(.name): \(.insight.detail)"' analysis.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## The kubectl → bundle map
|
|
107
|
+
|
|
108
|
+
| What you would run | Where it lives | Notes |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `get pods -n <ns> -o wide` | `cluster-resources/pods/<ns>.json` | Complete; pod IPs redacted |
|
|
111
|
+
| `get <kind> -n <ns> -o yaml` | `cluster-resources/<kind>/<ns>.json` | Live API objects incl. `.status` |
|
|
112
|
+
| `describe pod` | `cluster-resources/pods/<ns>.json` | Reconstruct it — no `describe` output is ever stored |
|
|
113
|
+
| `top pods -n <ns>` | `node-metrics/<node>.json` | Raw kubelet summary API; richer than `top` |
|
|
114
|
+
| `describe node` (capacity, conditions) | `cluster-resources/nodes.json` + `node-metrics/` | Complete |
|
|
115
|
+
| `describe node` (**Events**) | — | **Not collected.** See Known gaps |
|
|
116
|
+
| `get events -n <ns>` | `cluster-resources/events/<ns>.json` | Short TTL window only |
|
|
117
|
+
| `get ingress -n <ns>` | `cluster-resources/ingress/<ns>.json` | Spec + status; no events |
|
|
118
|
+
| `logs <pod> [-c <container>]` | `cluster-resources/pods/logs/<ns>/<pod>/<container>.log` | Filename is the **container** name |
|
|
119
|
+
| `logs <pod> --previous` | `…/<container>-previous.log` | |
|
|
120
|
+
| `get pv,pvc,sc` | `cluster-resources/pvs.json`, `pvcs/<ns>.json`, `storage-classes.json` | |
|
|
121
|
+
| `helm get values` | — | **Not collected.** See Config and Helm values |
|
|
122
|
+
|
|
123
|
+
Cluster-scoped resources are single files at `cluster-resources/*.json` (`nodes`, `pvs`,
|
|
124
|
+
`namespaces`, `clusterroles`, `storage-classes`, `custom-resource-definitions`, …). Namespaced
|
|
125
|
+
resources are a directory per kind with one file per namespace.
|
|
126
|
+
|
|
127
|
+
## Namespaces and workloads
|
|
128
|
+
|
|
129
|
+
The namespaces that carry workloads worth reading. This is a curated subset, not the full list — a
|
|
130
|
+
bundle also contains `default`, `kube-public`, `kube-node-lease`, and `k0s-autopilot` (k0s's
|
|
131
|
+
in-place updater). Enumerate `cluster-resources/pods/*.json` rather than assuming this table is
|
|
132
|
+
exhaustive.
|
|
133
|
+
|
|
134
|
+
| Namespace | Contents |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `openhands` | The application — all OpenHands services plus `runtime-*` sandbox pods |
|
|
137
|
+
| `kotsadm` | KOTS admin console, rqlite, kurl-proxy |
|
|
138
|
+
| `kube-system` | calico, coredns, kube-proxy, metrics-server |
|
|
139
|
+
| `cert-manager` | cert-manager, cainjector, webhook, trust-manager |
|
|
140
|
+
| `traefik` | Ingress controller |
|
|
141
|
+
| `openebs` | Local PV provisioner (backs `openebs-hostpath` PVCs) |
|
|
142
|
+
| `embedded-cluster` | Embedded-cluster operator |
|
|
143
|
+
|
|
144
|
+
Platform pods in `openhands`, roughly: `openhands` (frontend/API), `openhands-integrations`,
|
|
145
|
+
`openhands-mcp`, `openhands-litellm`, `openhands-runtime-api`, `openhands-minio`,
|
|
146
|
+
`openhands-redis-master`, `keycloak`, `automation`, `plugin-directory`, `replicated` (SDK), plus
|
|
147
|
+
CronJobs (`openhands-runtime-api-cleanup`, `-db-cleanup`, `-warm-runtimes`, …).
|
|
148
|
+
|
|
149
|
+
`runtime-*` pods are per-conversation agent sandboxes. There can be dozens to hundreds, each with
|
|
150
|
+
its own Deployment, Service, and PVC. They dominate pod counts, event counts, and analyzer output —
|
|
151
|
+
filter them out before drawing conclusions about platform health, then look at them separately.
|
|
152
|
+
|
|
153
|
+
Postgres may be **external** (a `DB_HOST` outside the cluster) rather than the in-cluster
|
|
154
|
+
StatefulSet. Check the deployment env before assuming a `postgresql` pod should exist.
|
|
155
|
+
|
|
156
|
+
## Reading pod state
|
|
157
|
+
|
|
158
|
+
`.status.phase` is not what `kubectl` prints in the STATUS column. To get CrashLoopBackOff,
|
|
159
|
+
ImagePullBackOff, `Init:Error`, or Terminating you must walk `.status.containerStatuses[].state`
|
|
160
|
+
and `.status.initContainerStatuses[]`, exactly as kubectl's printer does. `bundle_triage.py`
|
|
161
|
+
implements this — reuse it rather than reimplementing.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
NS=openhands
|
|
165
|
+
|
|
166
|
+
# Restart counts, highest first
|
|
167
|
+
jq -r '.items[] | {n:.metadata.name,
|
|
168
|
+
r:([(.status.containerStatuses//[])[].restartCount]|add // 0)}
|
|
169
|
+
| select(.r>0) | "\(.r)\t\(.n)"' cluster-resources/pods/$NS.json | sort -rn
|
|
170
|
+
|
|
171
|
+
# Containers currently waiting (CrashLoopBackOff, ImagePullBackOff, …)
|
|
172
|
+
jq -r '.items[] | .metadata.name as $p
|
|
173
|
+
| (.status.containerStatuses//[])[]
|
|
174
|
+
| select(.state.waiting)
|
|
175
|
+
| "\($p)\t\(.name)\t\(.state.waiting.reason)\t\(.state.waiting.message//"")"' \
|
|
176
|
+
cluster-resources/pods/$NS.json
|
|
177
|
+
|
|
178
|
+
# Why every Pending pod is Pending
|
|
179
|
+
jq -r '.items[] | select(.status.phase=="Pending")
|
|
180
|
+
| (.status.conditions//[])[] | select(.type=="PodScheduled" and .status!="True")
|
|
181
|
+
| .message' cluster-resources/pods/$NS.json | sort | uniq -c | sort -rn
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Why a pod is Pending** lives in the `PodScheduled` condition — the scheduler's message names the
|
|
185
|
+
actual blocking predicate (insufficient CPU, unbound PVC, taint). Read it before assuming resource
|
|
186
|
+
pressure.
|
|
187
|
+
|
|
188
|
+
### Checking for OOMKills — three independent sources
|
|
189
|
+
|
|
190
|
+
Check all three. A clean result from any one of them alone is weak evidence.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
# 1. Container state on any pod, any namespace
|
|
194
|
+
grep -l OOMKilled cluster-resources/pods/*.json || echo "no OOMKilled in any pod object"
|
|
195
|
+
|
|
196
|
+
# 2. Events (only within the events TTL window)
|
|
197
|
+
jq -r '[(.items//[])[] | select((.reason+.message) | test("OOM|Evict|MemoryPressure";"i"))] | length' \
|
|
198
|
+
cluster-resources/events/openhands.json
|
|
199
|
+
|
|
200
|
+
# 3. The pre-computed analyzer
|
|
201
|
+
jq -r '.[] | select(.name|test("oom")) | "\(.name): \(.insight.detail)"' analysis.json
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Simultaneous restarts are a node reboot, not a crash
|
|
205
|
+
|
|
206
|
+
If many containers across unrelated namespaces share one `lastState.terminated.finishedAt` with
|
|
207
|
+
`exitCode: 255` / `reason: Unknown`, the host restarted. Reading those as per-workload failures
|
|
208
|
+
sends you down the wrong path. `bundle_triage.py` flags this automatically.
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
# Abnormal terminations across every namespace, sorted by finish time
|
|
212
|
+
for f in cluster-resources/pods/*.json; do
|
|
213
|
+
jq -r --arg ns "$(basename "$f" .json)" '
|
|
214
|
+
(.items//[])[] | .metadata.name as $p
|
|
215
|
+
| ((.status.containerStatuses//[]) + (.status.initContainerStatuses//[]))[]
|
|
216
|
+
| . as $c | (.lastState.terminated // .state.terminated)
|
|
217
|
+
| select(. != null and .reason != "Completed")
|
|
218
|
+
| "\($ns)\t\($p)\t\($c.name)\t\(.reason)\texit=\(.exitCode)\t\(.finishedAt)"
|
|
219
|
+
' "$f"
|
|
220
|
+
done | sort -t$'\t' -k6
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Resource pressure
|
|
224
|
+
|
|
225
|
+
Two different questions, two different sources:
|
|
226
|
+
|
|
227
|
+
- **Actual usage** — `node-metrics/<node>.json`, the kubelet summary API. Per-pod and per-container
|
|
228
|
+
CPU, memory working set, and ephemeral storage, plus node-level `fs` / `imageFs` capacity.
|
|
229
|
+
- **Scheduling headroom** — sum each pod's *effective* request over pods that have a
|
|
230
|
+
`.spec.nodeName` **and are still `Running` or `Pending`**, then compare to `.status.allocatable`
|
|
231
|
+
in `nodes.json`. This is what `kubectl describe node` shows under "Allocated resources", and it is
|
|
232
|
+
what the scheduler acts on. Two things make this easy to get wrong:
|
|
233
|
+
|
|
234
|
+
- A pod's effective request is `max(sum(regular containers), max(init container))`, **not** the sum
|
|
235
|
+
of both — init containers finish before the regular ones start. Native sidecars
|
|
236
|
+
(`initContainers` with `restartPolicy: Always`) keep running, so they belong in the regular sum.
|
|
237
|
+
- `Succeeded` pods still carry a `nodeName`. Counting completed Jobs inflates the total against a
|
|
238
|
+
node that has long since reclaimed their capacity.
|
|
239
|
+
|
|
240
|
+
`bundle_triage.py --section alloc` applies neither rule — it adds init-container requests to the
|
|
241
|
+
regular sum, so any pod with init steps is over-charged. Treat its percentages as a starting point
|
|
242
|
+
and recompute by hand before concluding a node is short of capacity.
|
|
243
|
+
|
|
244
|
+
Requests, not usage, decide whether the next pod schedules. A node at 17% memory usage can still
|
|
245
|
+
refuse to schedule anything. Check `ephemeral-storage` too — it is a real and frequently-hit
|
|
246
|
+
ceiling that nobody thinks to look at.
|
|
247
|
+
|
|
248
|
+
A `Pending` pod is not always short of resources. If it carries a `spec.nodeSelector` or a
|
|
249
|
+
`spec.affinity`, the scheduler will only consider nodes matching it, and free capacity elsewhere
|
|
250
|
+
counts for nothing. Check the constraint against the labels actually present before reading the
|
|
251
|
+
allocation numbers as the answer:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# What the pod demands of a node
|
|
255
|
+
jq -r '.items[] | select(.status.phase == "Pending")
|
|
256
|
+
| .metadata.name, (.spec.nodeSelector // {}), (.spec.affinity // {})' \
|
|
257
|
+
cluster-resources/pods/openhands.json
|
|
258
|
+
|
|
259
|
+
# What the nodes actually offer
|
|
260
|
+
jq -r '.items[] | .metadata.name + "\t" + ((.metadata.labels // {}) | to_entries
|
|
261
|
+
| map(select(.key | test("node-role|openhands.dev"))) | map(.key) | join(","))' \
|
|
262
|
+
cluster-resources/nodes.json
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Node labels are applied at join time and are not reconciled afterwards, so an absent label is not
|
|
266
|
+
evidence the role was never intended. `Pending` with no `nodeName` and no scheduling event is the
|
|
267
|
+
signature of a constraint nothing satisfies — see the events caveat below, since the bundle may not
|
|
268
|
+
have captured the reason.
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
NODE=$(ls node-metrics/*.json | head -1)
|
|
272
|
+
|
|
273
|
+
# Node totals
|
|
274
|
+
jq '{cpu_cores: (.node.cpu.usageNanoCores/1e9),
|
|
275
|
+
mem_workingset_gib: (.node.memory.workingSetBytes/1073741824),
|
|
276
|
+
mem_available_gib: (.node.memory.availableBytes/1073741824),
|
|
277
|
+
nodefs_used_gib: (.node.fs.usedBytes/1073741824),
|
|
278
|
+
nodefs_cap_gib: (.node.fs.capacityBytes/1073741824)}' "$NODE"
|
|
279
|
+
|
|
280
|
+
# kubectl top pods, sorted by memory
|
|
281
|
+
jq -r '.pods[] | "\(.podRef.namespace)\t\(.podRef.name)\t\(((.memory.workingSetBytes//0)/1048576)|floor)Mi"' \
|
|
282
|
+
"$NODE" | sort -t$'\t' -k3 -rn | head -20
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Allocated requests vs allocatable needs quantity parsing — use
|
|
286
|
+
`python3 scripts/bundle_triage.py <bundle> --section alloc`.
|
|
287
|
+
|
|
288
|
+
## Events
|
|
289
|
+
|
|
290
|
+
`cluster-resources/events/<ns>.json` covers **only the API server's event TTL window** — often as
|
|
291
|
+
little as one hour. Anything older is gone.
|
|
292
|
+
|
|
293
|
+
An empty file is `{"kind":"EventList", …, "items": null}` and means "no events in the window", not
|
|
294
|
+
"nothing happened". Most namespaces in a healthy bundle are empty.
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
NS=openhands
|
|
298
|
+
|
|
299
|
+
# The window the bundle actually covers — state this in any conclusion you draw
|
|
300
|
+
jq -r '(.items//[])[] | (.lastTimestamp // .eventTime // .metadata.creationTimestamp)' \
|
|
301
|
+
cluster-resources/events/$NS.json | sort | sed -n '1p;$p'
|
|
302
|
+
|
|
303
|
+
# Warnings, deduped
|
|
304
|
+
jq -r '(.items//[])[] | select(.type=="Warning") | "\(.reason)\t\(.message)"' \
|
|
305
|
+
cluster-resources/events/$NS.json | sed -E 's/"[^"]*"/"X"/g' | sort | uniq -c | sort -rn
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Logs
|
|
309
|
+
|
|
310
|
+
One canonical location. Everything else is symlinks into it.
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
cluster-resources/pods/logs/<namespace>/<pod-name>/<container-name>.log
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
find cluster-resources/pods/logs -path "*runtime-api*"
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**The filename is the container name, not the pod name.** Every init container gets its own file,
|
|
321
|
+
so a pod directory may look like:
|
|
322
|
+
|
|
323
|
+
```
|
|
324
|
+
openhands-runtime-api-6b55f68bb-g6kb6/
|
|
325
|
+
├── wait-for-db.log # init container
|
|
326
|
+
├── create-db.log # init container
|
|
327
|
+
├── migrate-db.log # init container ← failures here are invisible to `kubectl logs <pod>`
|
|
328
|
+
├── runtime-api.log
|
|
329
|
+
└── runtime-api-previous.log # kubectl logs --previous
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Init-container logs are frequently where the real failure is, and are the easiest thing in the
|
|
333
|
+
bundle to miss.
|
|
334
|
+
|
|
335
|
+
Convenience directories (`podlogs/`, `runtime-sandboxes/logs/`, `replicated/logs/`, parts of `app/`)
|
|
336
|
+
are **symlink farms** pointing back at the canonical path. Do not treat them as separate evidence.
|
|
337
|
+
`find -type f` shows them as empty; use `find -type l` or `find -L`.
|
|
338
|
+
|
|
339
|
+
Host-level journald logs live outside the pod tree: `k0scontroller/<node>.log`,
|
|
340
|
+
`k0sworker/<node>.log`, `local-artifact-mirror/<node>.log`. These partly compensate for the missing
|
|
341
|
+
node events.
|
|
342
|
+
|
|
343
|
+
### Triaging a log file
|
|
344
|
+
|
|
345
|
+
Logs are the largest thing in a bundle and the easiest to skim badly. Three moves, in order.
|
|
346
|
+
|
|
347
|
+
The same three moves apply to logs a user pastes into the conversation, with no bundle involved —
|
|
348
|
+
read them directly rather than reaching for the commands. Check the format before filtering, group
|
|
349
|
+
by message shape rather than counting lines, and find the burst before reading line by line. A
|
|
350
|
+
pasted excerpt carries two extra hazards worth naming: it is a *selection*, chosen by someone who
|
|
351
|
+
already had a theory, so the cause may sit in the lines just before what you were given — ask for
|
|
352
|
+
more context around the interesting entry rather than reasoning from the fragment. And it usually
|
|
353
|
+
arrives with no filename, so you do not know which container produced it; ask, because a stack trace
|
|
354
|
+
from an init container and the same trace from the main container mean different things. Where a
|
|
355
|
+
timestamp is present, place it against when the symptom started before treating it as the cause.
|
|
356
|
+
|
|
357
|
+
**1. Check the format before filtering.** Log files are a mix of JSON-per-line and plain text, and
|
|
358
|
+
the same file often contains both. Most files in a bundle are entirely plain text; only the OpenHands
|
|
359
|
+
application services log JSON. A `jq`- or `.severity`-based filter **silently skips every non-JSON
|
|
360
|
+
line**, which is where a lot of real failures live — an uncaught Python exception is printed bare,
|
|
361
|
+
with no `severity` field and often without the word "error" at all.
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
# What am I dealing with? (JSON lines vs total)
|
|
365
|
+
for f in <logs>; do
|
|
366
|
+
total=$(wc -l < "$f"); js=$(grep -c '^{' "$f")
|
|
367
|
+
echo "$((total-js))\tnon-JSON\t$js\tJSON\t$f"
|
|
368
|
+
done | sort -rn
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Always make a separate pass over the non-JSON lines:
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
grep -v '^{' app.log | grep -vE '^\s*$' | sort | uniq -c | sort -rn | head -30
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
**2. Cluster by message shape, not by count.** Raw counts mislead badly: 107 warnings in one
|
|
378
|
+
observed log were a single issue repeated inside a four-second window, and 224 of 228 non-JSON lines
|
|
379
|
+
in another were one repeated message. Normalise the volatile parts (UUIDs, numbers, addresses) and
|
|
380
|
+
count the shapes. Key on the *message*, not a logger or module field — some records have neither.
|
|
381
|
+
|
|
382
|
+
Note the `grep '^{'` in front of every `jq`: without it `jq` hits the first plain-text line and
|
|
383
|
+
aborts with `Invalid numeric literal`, printing nothing. A silent empty result from a file you know
|
|
384
|
+
has content means the filter died, not that the cluster is clean.
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
grep '^{' app.log | jq -r 'select(.severity=="WARNING") | .message' \
|
|
388
|
+
| sed -E 's/[0-9a-f-]{36}/<uuid>/g; s/[0-9]+/<n>/g' \
|
|
389
|
+
| cut -c1-110 | sort | uniq -c | sort -rn
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Typical OpenHands service records carry `ts`, `severity`, `message`, `module`, `funcName`, `lineno`.
|
|
393
|
+
|
|
394
|
+
**3. Bucket by time before concluding anything.** A flat rate is usually health probes; incidents are
|
|
395
|
+
bursts. Compare the log's own span against the bundle capture time — steady low-volume traffic across
|
|
396
|
+
hours with one dense cluster tells you where to look, and a count alone does not.
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
grep '^{' app.log | jq -r '.ts[:16]' | sort | uniq -c
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Then read the burst, not the file.
|
|
403
|
+
|
|
404
|
+
Three traps:
|
|
405
|
+
|
|
406
|
+
- **Truncation.** Some logs are capped and keep only the *tail*. Find them by looking for files that
|
|
407
|
+
share one exact large size: `find cluster-resources/pods/logs -type f -size +1000000c -printf '%s\n' | sort | uniq -c`.
|
|
408
|
+
Absence of an early error in a capped file proves nothing.
|
|
409
|
+
- **Empty ≠ silent.** Pods that never started still get 2-byte `.log` files. Cross-check
|
|
410
|
+
`.status.phase` before concluding a pod logged nothing.
|
|
411
|
+
- **Missing pods.** Succeeded Job pods and occasionally a Running pod have no log directory at all.
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
# Pods with no logs at all
|
|
415
|
+
comm -23 \
|
|
416
|
+
<(jq -r '.items[].metadata.name' cluster-resources/pods/openhands.json | sort) \
|
|
417
|
+
<(ls cluster-resources/pods/logs/openhands | sort)
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
## Redaction
|
|
421
|
+
|
|
422
|
+
Redacted values appear as `***HIDDEN***`. The redactor is heuristic and **over-redacts**: it hides
|
|
423
|
+
pod IPs, CIDRs inside `NO_PROXY`, and non-secret values that merely pattern-match.
|
|
424
|
+
|
|
425
|
+
`***HIDDEN***` therefore means "a value exists here that was redacted" — **not** "this is a secret"
|
|
426
|
+
and **not** "this is unset". You can always confirm that an env var is set and where its value comes
|
|
427
|
+
from; you cannot always read the value.
|
|
428
|
+
|
|
429
|
+
Over-redaction does not imply completeness. The redactor is pattern-based, so identifying data it
|
|
430
|
+
has no pattern for — hostnames, user and installation identifiers, internal URLs — routinely
|
|
431
|
+
survives into the bundle. Treat the archive as confidential regardless of how much of it is masked,
|
|
432
|
+
and redact identifiers yourself before quoting bundle contents anywhere public.
|
|
433
|
+
|
|
434
|
+
## Config and Helm values
|
|
435
|
+
|
|
436
|
+
**Not in the bundle:** rendered Helm values, `sh.helm.release.v1.*` secrets, and the KOTS
|
|
437
|
+
`ConfigValues` object holding the config answers. A grep for `ConfigValues` returns nothing.
|
|
438
|
+
|
|
439
|
+
**In the bundle**, inside the KOTS app archive:
|
|
440
|
+
|
|
441
|
+
```bash
|
|
442
|
+
tar -xf "$(find kots -name '*.tar')" -C /tmp/kotsapp && ls /tmp/kotsapp
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
- `openhands.yaml` — the KOTS `HelmChart` CR with the full `spec.values`, but **still templated**:
|
|
446
|
+
hundreds of unrendered `repl{{ ConfigOption "…" }}` expressions. You get the rule, not the input.
|
|
447
|
+
- `config.yaml` — the KOTS `Config` *schema*, with no answers.
|
|
448
|
+
- `<chart>-<version>.tgz` — the complete chart and subcharts, including every default `values.yaml`.
|
|
449
|
+
- `kots/admin_console/license.yaml` — license and entitlements.
|
|
450
|
+
|
|
451
|
+
**Backing out the effective config.** Because the live objects hold the *rendered* result, you can
|
|
452
|
+
solve most template expressions backwards: read the template in `openhands.yaml`, then find the
|
|
453
|
+
corresponding rendered value in the live object.
|
|
454
|
+
|
|
455
|
+
| ConfigOption | Evidence | Inferred |
|
|
456
|
+
|---|---|---|
|
|
457
|
+
| `hostname_mode`, `base_domain` | `WEB_HOST` matches the `app.{{base_domain}}` branch | `derive` + the base domain |
|
|
458
|
+
| `postgres_type` | `DB_HOST` ≠ the chart's internal service name | `external_postgres` |
|
|
459
|
+
| `external_postgres_host`, `_ssl_mode` | `DB_HOST`, `DB_SSL_MODE` | direct read |
|
|
460
|
+
| `log_level` | `LOG_LEVEL` | direct read |
|
|
461
|
+
| `proxy_enabled`, `https_proxy` | `HTTPS_PROXY` | on + the proxy URL |
|
|
462
|
+
| `ssl_verify` | `SSL_VERIFY=False` | disabled |
|
|
463
|
+
| `runtime_routing_mode` | `RUNTIME_ROUTING_MODE` | direct read |
|
|
464
|
+
| `custom_sandbox_image_*` | `AGENT_SERVER_IMAGE_REPOSITORY` + `_TAG` | registry/repo/tag |
|
|
465
|
+
| `sandbox_cpu_limit`, `_memory_limit` | a `runtime-*` pod's `.spec.containers[0].resources` | direct read |
|
|
466
|
+
|
|
467
|
+
Where it breaks: any option whose rendered output the redactor hides, and every `*_password` /
|
|
468
|
+
`*_secret` / `*_api_key`. Those are not recoverable — read them from your own config instead.
|
|
469
|
+
|
|
470
|
+
Useful for the auth, LLM, and certificate failure modes in `diagnostics.md`, where the question is
|
|
471
|
+
usually "is this configured the way we think it is":
|
|
472
|
+
|
|
473
|
+
```bash
|
|
474
|
+
NS=openhands
|
|
475
|
+
|
|
476
|
+
# All env vars on a deployment, resolving valueFrom references
|
|
477
|
+
jq -r '.items[] | select(.metadata.name=="openhands")
|
|
478
|
+
| .spec.template.spec.containers[0].env[]
|
|
479
|
+
| "\(.name)\t\(
|
|
480
|
+
if .value then .value
|
|
481
|
+
elif .valueFrom.secretKeyRef then "<secret \(.valueFrom.secretKeyRef.name)/\(.valueFrom.secretKeyRef.key)>"
|
|
482
|
+
elif .valueFrom.configMapKeyRef then "<cm \(.valueFrom.configMapKeyRef.name)/\(.valueFrom.configMapKeyRef.key)>"
|
|
483
|
+
else "<other>" end)"' cluster-resources/deployments/$NS.json | column -t -s $'\t'
|
|
484
|
+
|
|
485
|
+
# Not every workload is a Deployment, and StatefulSet capture is version-dependent.
|
|
486
|
+
# Keycloak, redis and rqlite are StatefulSets; older bundle specs omit them
|
|
487
|
+
# entirely, so an absent statefulsets/ means "not collected", not "not deployed".
|
|
488
|
+
# Fall back to the pod list, which is always present.
|
|
489
|
+
ls cluster-resources/statefulsets/ 2>/dev/null || \
|
|
490
|
+
jq -r '.items[].metadata.name' cluster-resources/pods/$NS.json
|
|
491
|
+
|
|
492
|
+
# LiteLLM model config
|
|
493
|
+
jq -r '.items[] | select(.metadata.name=="openhands-litellm-config") | .data["config.yaml"]' \
|
|
494
|
+
cluster-resources/configmaps/$NS.json
|
|
495
|
+
|
|
496
|
+
# Secret keys (values redacted, but the keys tell you the wiring)
|
|
497
|
+
ls secrets/$NS/
|
|
498
|
+
|
|
499
|
+
# Ingress overview — hosts and TLS wiring for certificate issues
|
|
500
|
+
jq -r '.items[] | "\(.metadata.name)\t\(.spec.ingressClassName)\t\([.spec.rules[].host]|join(","))"' \
|
|
501
|
+
cluster-resources/ingress/$NS.json | column -t -s $'\t'
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Also diff the **pod** against the **deployment**. If a rollout never completed, the running
|
|
505
|
+
container's env differs from the deployment spec, and only the pod tells you what is actually live.
|
|
506
|
+
|
|
507
|
+
## Known gaps
|
|
508
|
+
|
|
509
|
+
These are the things a bundle cannot tell you. Say so explicitly rather than inferring.
|
|
510
|
+
|
|
511
|
+
- **No node-scoped events.** No event of `kind: Node` exists anywhere. `SystemOOM`, `NodeNotReady`,
|
|
512
|
+
and kubelet eviction events are absent. Node-level incidents must be inferred from correlated pod
|
|
513
|
+
restart timestamps or the k0s journald logs.
|
|
514
|
+
- **No `describe` output** for anything. Reconstruct from JSON, which has strictly more — minus events.
|
|
515
|
+
- **One instant only.** `node-metrics` is a single sample. No history, so "was it spiking an hour
|
|
516
|
+
ago" is unanswerable. Partial mitigation:
|
|
517
|
+
`kots/admin_console/kotsadm/*/kotsadm/tmp/last-preflight-result/` holds a second full snapshot
|
|
518
|
+
from the last preflight run, giving you two points to compare.
|
|
519
|
+
- **Events are TTL-limited** (see above).
|
|
520
|
+
- **Collector-error paths are usually directories.** `collector-errors/` and `secrets-errors/` are
|
|
521
|
+
directories recording collector failures, not data — but the `-errors` suffix is not a reliable
|
|
522
|
+
signal on its own: some genuine `*-errors.json` files exist (for example under `kots/goldpinger/`).
|
|
523
|
+
Stat the path rather than assuming from the name.
|
|
524
|
+
|
|
525
|
+
## When the bundle looks clean but the user has a problem
|
|
526
|
+
|
|
527
|
+
This is the normal hard case, not an edge case. Nobody sends a support bundle because things are
|
|
528
|
+
working, so "no findings" means the failure is somewhere the cluster-resources objects do not
|
|
529
|
+
reach — not that the install is fine. What a clean triage run has actually ruled out is narrow:
|
|
530
|
+
pod objects, analyzer verdicts, node conditions, and resource totals.
|
|
531
|
+
|
|
532
|
+
What it has *not* looked at, roughly in order of how often it pays off:
|
|
533
|
+
|
|
534
|
+
1. **Application logs.** The script reads none. A component can be `Running`, `1/1 Ready`, zero
|
|
535
|
+
restarts, and failing every request. Start with the logs for whatever the user's symptom points
|
|
536
|
+
at, and see the log triage section above — a severity filter that silently skips non-JSON lines
|
|
537
|
+
will show you nothing on a file full of errors.
|
|
538
|
+
2. **Anything outside the capture window.** Events have a short TTL and the bundle is one instant.
|
|
539
|
+
An incident that resolved before capture leaves almost nothing behind; `lastState`, restart
|
|
540
|
+
counts, and pod age are the only real memory the bundle has.
|
|
541
|
+
3. **Config, not runtime.** A wrong secret, a bad URL, a disabled feature flag, an expired
|
|
542
|
+
licence — all render as a healthy pod. Check the ConfigMaps and the app config, and remember
|
|
543
|
+
`***HIDDEN***` means redacted, not unset.
|
|
544
|
+
4. **Whatever the collectors missed.** Check `collector-errors/` before concluding a subsystem is
|
|
545
|
+
healthy: an empty or absent file is indistinguishable from a clean one, and a failed collector
|
|
546
|
+
produces exactly that.
|
|
547
|
+
5. **Off-cluster.** Ingress, DNS, TLS, an external database, an egress proxy, the licence endpoint.
|
|
548
|
+
Nothing outside the cluster appears in these objects at all.
|
|
549
|
+
|
|
550
|
+
If you find nothing, say so plainly and ask for what would actually settle it — the exact user-facing
|
|
551
|
+
symptom, a timestamp, and the component involved. "The bundle looks healthy" is an unhelpful answer
|
|
552
|
+
on its own, and a confident wrong diagnosis drawn from a clean bundle is worse than none.
|
|
553
|
+
|
|
554
|
+
## Anti-patterns
|
|
555
|
+
|
|
556
|
+
- Concluding "no OOM" from the pod objects alone — check events and `analysis.json` too, and state
|
|
557
|
+
that the events window is short.
|
|
558
|
+
- Reading a simultaneous mass restart as a crash loop.
|
|
559
|
+
- Trusting file mtimes for timing.
|
|
560
|
+
- Treating `***HIDDEN***` as "secret" or "unset".
|
|
561
|
+
- Counting the symlink farms as additional log coverage.
|
|
562
|
+
- Reporting "the pod logged nothing" from an empty 2-byte file.
|
|
563
|
+
- Assuming a Pending pod is resource-starved without reading the `PodScheduled` condition message —
|
|
564
|
+
and then assuming `PodScheduled` is where the answer lives. **Pending does not mean unschedulable.**
|
|
565
|
+
The common case in practice is a pod that scheduled fine and is stuck starting
|
|
566
|
+
(`CreateContainerConfigError`, `ImagePullBackOff`), which has a `nodeName` and `PodScheduled=True`;
|
|
567
|
+
its reason is in `.status.initContainerStatuses[].state.waiting` / `containerStatuses[]`, not in
|
|
568
|
+
the conditions. Check both.
|
|
569
|
+
- Drawing platform-health conclusions from pod counts inflated by `runtime-*` sandboxes.
|
|
570
|
+
- Running a severity filter over a log without first checking whether the file is even JSON.
|