Skip to content

feat: add openhands-enterprise-troubleshooting skill for OHE self-hosted debugging - #456

Open
jpelletier1 wants to merge 28 commits into
mainfrom
skill/openhands-enterprise-troubleshooting
Open

feat: add openhands-enterprise-troubleshooting skill for OHE self-hosted debugging#456
jpelletier1 wants to merge 28 commits into
mainfrom
skill/openhands-enterprise-troubleshooting

Conversation

@jpelletier1

@jpelletier1 jpelletier1 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor
  • A human has tested these changes.

Why

Diagnosing a self-hosted OpenHands Enterprise install currently depends on knowledge that lives with individual engineers. The same eight failures recur — sandbox startup timeouts, git provider auth, certificates, LLM connectivity, Keycloak, the Replicated Admin Console, stuck upgrades, resource exhaustion — and each investigation restarts from nothing. Support bundles make this worse rather than better: they are large, and the signal is spread across analysis.json, per-namespace pod objects, and node metrics, so reading one by hand is slow and easy to get wrong.

This skill gives customers, FDEs, and first response a repeatable path from symptom to either a fix or an escalation that the platform team can act on without a second round of questions.

Summary

  • Adds the openhands-enterprise-troubleshooting skill: triage for the eight common failure modes, guided recovery that validates each step before the next, support bundle generation, and an escalation handoff template.
  • Adds references/diagnostics.md (per-failure-mode commands) and references/support-bundle-analysis.md (bundle layout, log triage, and how the bundle's own analyzers mislead).
  • Adds scripts/bundle_triage.py, which summarises a bundle offline — meta, analyzers, pods, restarts, top, alloc, events.
  • Documents how to rank findings into a most-likely root cause, including two traps in the tooling: warn-severity analyzers print under a FAIL heading, and per-object analyzers show one arbitrary example per family, so [x12] is a count and not a worst case.
  • Registers the skill in the marketplace catalogue and skills index.

Issue Number

Closes #527

How to Test

uv run pytest -q            # 786 passed, 14 skipped
npm run build:skills        # catalogue regenerates with no diff

To exercise the script against a real bundle:

python skills/openhands-enterprise-troubleshooting/scripts/bundle_triage.py <bundle-dir>
python skills/openhands-enterprise-troubleshooting/scripts/bundle_triage.py <bundle-dir> --section analyzers,pods

Read SKILL.md end to end — most of the change is prose, and its accuracy is the thing under review.

Video/Screenshots

n/a — no user interface.

Notes

scripts/bundle_triage.py is a verbatim copy of an existing internal script and is deliberately unmodified, so that improvements land at the source rather than diverging here. It has known rough edges: it raises on bundles missing node-metrics node info, on analyzer entries without an insight, and on nodes without pod capacity. Those are worth fixing upstream and then re-syncing.

Command accuracy was checked rather than assumed: every kubectl invocation was parsed and run against an unreachable cluster to surface flag and subcommand errors, every single-line jq program was compiled, and code blocks were scanned for undefined shell variables. That found two genuine errors, both fixed here — kc.sh health is not a Keycloak subcommand (health is HTTP on the management port, and only when enabled), and the database connectivity check referenced $DB_HOST/$DB_PORT, which are set nowhere and, inside single quotes, expanded in the pod rather than the shell.

The bundle paths documented in references/ were verified against real bundles in earlier sessions; those bundles are no longer on the machine, so they were not re-verified in this pass.


This description was updated by an AI agent (OpenHands) on behalf of @ai.vong.

@aivong-openhands aivong-openhands changed the title Add openhands-enterprise-troubleshooting skill for OHE self-hosted debugging feat: add openhands-enterprise-troubleshooting skill for OHE self-hosted debugging Aug 31, 2026
@github-actions github-actions Bot added the type: feat A new feature label Aug 31, 2026
…bugging

This skill helps diagnose and resolve common issues on OpenHands Enterprise
(OHE) self-hosted installations using Replicated VM-based infrastructure.

Features:
- Triage and diagnosis for 8 common failure modes
- Guided recovery with step-by-step resolution
- Support bundle generation and parsing
- Escalation handoff template for platform team

Derived from Linear issue PLTF-2910.
@aivong-openhands
aivong-openhands force-pushed the skill/openhands-enterprise-troubleshooting branch from 0192d51 to 1e2b079 Compare August 31, 2026 16:16
@aivong-openhands

aivong-openhands commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Added support bundle analysis

This skill covers generating a support bundle and reading one: the real on-disk layout, how to reconstruct cluster state from it, and a script that does the mechanical first pass.

What was added

  • references/support-bundle-analysis.md — the real bundle layout: a kubectl → bundle-path map, reconstructing pod state from cluster-resources/pods/<ns>.json, the three independent OOM sources and why they disagree, the node-reboot signature, log triage, and the gaps that produce convincing false negatives (an empty events file ≠ nothing happened; ***HIDDEN*** means redacted, not unset; log filenames are container names, so init-container failures like migrate-db are invisible to kubectl logs <pod>; mtimes reflect extraction time, not capture time).
  • scripts/bundle_triage.py — offline triage in one command: cluster meta, analyzer verdicts, pod table, OOM/restart scan, top equivalent, allocatable headroom, events. Standard library only.
  • Cross-references so each live-cluster failure mode in diagnostics.md maps to its offline equivalent.
  • A note that bundles carry potentially sensitive data and that the redactor is pattern-based, so it is not a guarantee.

Two things the tool deliberately won't do. It doesn't pretend to diagnose — it reports what the objects say and leaves to the reader which of those observations explains the user's symptom. And a clean run doesn't claim health: the guide states what a clean run actually rules out, notes that the script reads no application logs, and points to where to look next. Log analysis is deliberately left to the agent rather than the script: a fixed matcher only finds the patterns it was given and cannot tell you what it missed, whereas the guide's method — judge the format, cluster by message shape, bucket by time, read the burst — is exactly the kind of judgement an agent applies better than a regex. Nobody sends a support bundle because things are working, so "no findings" is the hard case, not the good one — and telling a broken customer their bundle looks healthy is the outcome most worth designing against.

Accuracy

Every command and object name is verified against the Replicated docs and the OpenHands-Cloud charts rather than inferred, because a plausible-looking wrong command costs a support engineer more than no command at all. What that check settled:

  • Bundle collection is sudo ./openhands support-bundle, with a kubectl support-bundle fallback for pre-1.17.0. The vendor-side replicated CLI is not present on a customer VM.
  • Namespaces: the Admin Console runs in kotsadm and the operator in embedded-cluster, and kubectl needs sudo ./openhands shell first.
  • Rollbacks go through the Admin Console, with a warning against hand-editing Helm state.
  • Git provider credentials live in per-provider secrets — github-app, gitlab-app, bitbucket-data-center-app, azure-devops-app.
  • Sandboxes are created on demand by runtime-api as individual runtime-* pods; there is no sandbox deployment or app=sandbox label.
  • The container runtime is the sysbox-runc RuntimeClass, registered per node by a DaemonSet.
  • The bundle layout is what troubleshoot.sh actually produces — cluster-resources/pods/<ns>.json and container-named logs, not pod-status.json or pod-logs/*.log.

Git provider coverage is provider-neutral. Bitbucket Data Center and Azure DevOps are first-class providers alongside GitHub and GitLab, each with its own secret and template. Both are self-hosted, so their host comes from configuration rather than a fixed domain, and an apparent auth failure is often really network or TLS — so the reader is prompted to establish which provider the user is on first.

The credential check reports key names and byte lengths rather than secret values, since a present-but-empty key is the failure worth finding and Helm renders empty values into valid-looking Secrets.

Validation

The interpretation rules were checked against real Embedded Cluster bundles, including unhealthy ones (failed migrations, a crash-looping operator, Pending pods, sandbox-inflated pod counts). Healthy bundles were used only to learn what normal looks like and to catch false positives; every claim about a failure mode was checked against a bundle actually exhibiting it, or is marked unproven.

Four cases where the obvious reading of the data is wrong, and which the guide therefore spells out: node conditions whose healthy value is True (ContainerdHasNoDeprecations is not a fault); init-container requests, whose true cost is max(sum(regular), max(init)) rather than the sum of both, with native sidecars (restartPolicy: Always) belonging in the running sum; analyzer results that must be kept apart by subsystem, so a real failure is not read as an instance of an unrelated one; and recovered terminations, which are distinguishable from active ones only by their age.

references/support-bundle-analysis.md carries a Validation status section recording what is verified against real data and what isn't. Still fixture-only: an actively OOMing container, a truly unschedulable pod, the EXCEEDS ALLOCATABLE path, the host-reboot heuristic, and native sidecars.

The triage script is a verbatim copy

bundle_triage.py is byte-identical to the script it is taken from, deliberately.

The guide covers scheduling constraints as their own failure mode: a Pending pod whose nodeSelector or affinity nothing satisfies is stuck regardless of how much capacity the cluster has, and the allocation numbers will not explain it. Both halves of the skill say to read the scheduler's own reason first — describe pod on a live cluster, the pod spec and node labels offline.

The skill ships the .plugin/plugin.json and vendor symlinks the marketplace tests require, and the generated catalog is current. Full suite: 786 passed.


This comment was created by an AI agent (OpenHands) on behalf of @ai.vong.

The support bundle section described a bundle layout that does not exist:
pod-status.json, pod-logs/*.log, events.json and nodes.json are not paths
troubleshoot.sh produces. The generation command was also invalid --
`replicated admin support-bundle` is a vendor-side CLI that is not present on a
customer VM.

Replace it with the real layout and commands:

- references/support-bundle-analysis.md: the kubectl -> bundle file map, pod
  state reconstruction, the three independent OOM sources, the node-reboot
  signature, container-vs-pod log naming, redaction semantics, and the gaps that
  produce false negatives.
- scripts/bundle_triage.py: offline first-pass triage, stdlib only.
- Correct generation command for Embedded Cluster (`sudo ./openhands
  support-bundle`), with the pre-1.17.0 fallback.

Also fixes commands in diagnostics.md that could not work as written: the Admin
Console lives in the kotsadm namespace rather than replicated, and the
preflight/rollback steps used vendor-side CLI calls. Rollback is now documented
as the Admin Console flow it actually is, with a warning against hand-editing
Helm state.

Adds the .plugin manifest the skill was missing, which was failing
test_all_marketplace_skills_have_plugin_json on CI, and regenerates the catalog
and skills index.

Co-authored-by: openhands <openhands@all-hands.dev>
@aivong-openhands
aivong-openhands force-pushed the skill/openhands-enterprise-troubleshooting branch from 1e2b079 to c023d01 Compare August 31, 2026 16:20
"Platform team" is internal org structure and does not name anything an
external reader can act on. This skill ships publicly, so point the handoff and
the bundle share at the OpenHands team instead.

Also states that a support bundle is confidential. The reference already noted
that the redactor over-redacts; add that over-redaction does not imply
completeness -- it is pattern-based, so hostnames and user/installation
identifiers routinely survive -- so a reader does not treat a heavily masked
bundle as safe to paste in public.

Co-authored-by: openhands <openhands@all-hands.dev>
…ndle

Running the triage script against a real, healthy Embedded Cluster bundle
surfaced three defects that the synthetic fixture could not, because the fixture
only contained cases already anticipated.

Node conditions were flagged with `type != Ready and status == True`, which
assumes every non-Ready condition is negative-polarity. Vendor conditions named
positively -- ContainerdHasNoDeprecations -- were therefore reported as problems
on a healthy node. Only Ready and the known pressure conditions are now
evaluated; unrecognised conditions are left unflagged rather than guessed at.

Allocated requests summed regular and init containers together. Init containers
run to completion before regular ones start, so a pod's effective request is
max(sum(regular), max(init)); the additive sum overstated CPU by ~24% on the
bundle tested. Native sidecars (restartPolicy: Always) keep running and are
counted with the regular containers. The reference recipe disagreed with the
script here and was wrong in its own way -- it omitted the phase filter, so
completed Jobs inflated the total -- and is corrected too.

OOMKilled containers were listed with no current state, so a kill recorded in
lastState on a pod that has long since recovered read as an active incident.
Entries now carry current state, readiness, and age, and only containers that
are not currently healthy keep the !! marker.

The three OOM sources disagree routinely: events age out of their TTL window and
the analyzer keys on events, so both report clean while container state still
records kills. The script printed those numbers in separate sections and left
the contradiction to the reader; it now reconciles them explicitly and says
which source to trust. The guide's claim that analysis.json is authoritative is
qualified for the same reason.

Adds log triage guidance. Most bundle logs are plain text rather than JSON, and
jq aborts on the first non-JSON line, so a severity filter can print nothing for
a file full of errors -- the recipes here hit exactly that and are written to
avoid it. Raw counts also mislead: in one observed log 107 warnings were a
single issue repeated within four seconds.

Also records which parts of the guide are verified against a real bundle and
which remain fixture-only, since an all-healthy bundle cannot exercise active
OOM, crash loops, Pending pods, or the failing-analyzer path.

Co-authored-by: openhands <openhands@all-hands.dev>
…osis

A second validation pass against a fleet of real bundles, including unhealthy
ones, reached paths the first healthy bundle could not.

Failing analyzers were grouped by wildcarding the whole middle of the dotted
name, so unrelated subsystems merged into one line: a db-cleanup failure and two
warm-runtimes failures collapsed to a single heading that printed only the
db-cleanup example, hiding the other two. Only identifier-shaped segments are
wildcarded now. The grouping also labelled every entry FAIL regardless of
severity, rendering warn-level analyzers identically to errors; entries now carry
their real severity, sort worst-first, and show up to three distinct messages.
The equivalent recipe in the guide over-merged the same way and is corrected.

The Pending-pod projection added those pods to a baseline that already included
them, since a Pending pod usually still carries a nodeName -- reporting 5.00
cores where the true total was 3.00, and biasing the EXCEEDS ALLOCATABLE flag
toward false positives. Only genuinely unscheduled pods are projected now.

Pending was also treated as synonymous with unschedulable. The most common real
cause -- scheduled onto a node but stuck in CreateContainerConfigError or
ImagePullBackOff -- has PodScheduled=True, so neither the script nor the guide
reported anything for it. Both now read container and init-container waiting
state as well.

Validation status is updated: analyzer grouping across severities, Pending pods
blocked in init, init-container attribution, and runtime-* collapse at scale are
now verified against real data. Active OOM, a truly unschedulable pod, the host
reboot heuristic, native sidecars, and the both-state double-count remain
fixture-only, and multi-node and HA installs remain entirely unverified.

Co-authored-by: openhands <openhands@all-hands.dev>
Nobody opens a support bundle because the install is working, but the tool was
organised as if they might: seven sections in collection order, roughly a
hundred lines, with the failure somewhere inside for the reader to spot. That is
the wrong shape for something only ever run when something is broken.

Output now opens with a FINDINGS block -- crash loops, active OOM kills, pods
outside Running/Succeeded, unready containers, failing analyzers, bad node
conditions -- ranked BROKEN NOW / DEGRADED / CONTEXT, each line naming the
section that explains it. Available alone via --section findings. The ranking is
mechanical and says so: it reflects what the objects report, not which finding
explains the reported symptom, and a currently-OOMing container is separated
from one that was killed and recovered.

A clean run is now the interesting case, so it is handled explicitly rather than
printing nothing. The empty state states what was actually ruled out -- pod
objects, analyzer verdicts, node conditions, resource totals -- and refuses to
call the install healthy, because the script reads no application logs at all. A
new section covers where to look next: logs, the capture window, config rather
than runtime, failed collectors, and off-cluster causes. Healthy bundles are for
learning what normal looks like and catching false positives; they cannot
confirm a failure mode, and the validation status now says so.

Also fixes a ZeroDivisionError predating this change: a node reporting no pod
capacity crashed the alloc section and took the whole run with it, which is
exactly the partially-collected bundle most in need of triage.

Co-authored-by: openhands <openhands@all-hands.dev>
Verified the platform-specific claims in this skill against the OpenHands-Cloud
charts. Several name objects that do not exist, so the commands fail rather than
mislead -- but the surrounding prose still sends the reader somewhere wrong.

Podman is not supported and appears nowhere in OpenHands-Cloud. Sandboxes run
under the sysbox-runc RuntimeClass, backed by a sysbox containerd runtime that a
DaemonSet registers on each node. That registration failing is a real failure
mode -- every sandbox stays Pending while the rest of the platform looks
healthy -- and it now replaces the crictl check.

The git provider section covered only GitHub and GitLab. Bitbucket Data Center
is a first-class provider with its own secret and template, as is Azure DevOps.
Both are self-hosted, so their host comes from configuration rather than a fixed
domain, and an apparent auth failure is often really network or TLS. The section
is now provider-neutral and prompts the reader to establish which provider the
user is on before diagnosing.

Object names are corrected to what the charts actually create. There is no
git-provider-secret; each provider has its own secret (github-app, gitlab-app,
bitbucket-data-center-app, azure-devops-app) consumed as environment variables
by the main openhands deployment, so provider failures surface in the app's logs
rather than a dedicated workload. There is no app=sandbox label or sandbox
deployment either: sandboxes are created on demand by runtime-api as individual
runtime-* pods.

The credential check no longer prints secret values, and reports key names with
byte lengths instead -- a present-but-empty key is the failure worth finding,
since Helm renders an empty value into a valid-looking Secret.

Co-authored-by: openhands <openhands@all-hands.dev>
The guide devotes about a hundred lines to reading logs -- format checking,
clustering by message shape, time bucketing, init-container attribution -- and
the script read none of them. Reading logs is most of what analysing a support
bundle is, so leaving that entirely manual made the tool stop exactly where the
work starts.

Adds a logs section covering every canonical container log. It handles both
formats in the bundle: JSON-per-line records via their severity field, and plain
text via the failure patterns that carry no severity at all -- tracebacks, bare
exceptions, panics, connection refusals, certificate errors. That second half
matters because an uncaught exception has no severity field and often lacks the
word "error", so a jq-based filter skips it silently.

Results are counted by message shape rather than raw line, with volatile parts
normalised, because raw counts mislead: one message repeated two hundred times
is one incident. Init containers are covered, so a failed migrate-db -- which is
invisible to kubectl logs <pod> and which the guide calls the easiest thing in
the bundle to miss -- now surfaces without being hunted for. The canonical log
tree is read directly and symlinks skipped, so the convenience trees do not
double-count.

The loudest logs also feed the findings block, ranked alongside broken objects
rather than beneath them, since a log is the only place a failure that leaves
every object healthy can appear.

Coverage claims are corrected accordingly: the script no longer says it reads no
logs, and now states the real limit, which is that a failure logging neither a
severity field nor a recognisable pattern still will not appear. Validation
status records the scan as fixture-verified only -- the patterns come from real
bundles, but recall against a real log corpus is unproven.

Co-authored-by: openhands <openhands@all-hands.dev>
This reverts commit 7c022ba.

Log analysis is a poor fit for a fixed script and a good fit for the agent. The
script can only match patterns it was told about, and its recall was unproven --
it reports what it found and nothing reports what it missed, which is the worst
property for a check whose clean result invites a "no errors" conclusion. An
agent reading the same logs can follow the guide's actual method: judge the
format, cluster by shape, bucket by time, and read the burst in context.

The guide keeps its log triage section, so the capability is documented rather
than automated.

Co-authored-by: openhands <openhands@all-hands.dev>
The section divides cluster-wide pod requests by the first node's
allocatable, so percentages inflate with node count and EXCEEDS
ALLOCATABLE stops meaning anything. No multi-node bundle is available to
verify a fix against, so warn at the point of use and record the
limitation rather than guess at a correction.

Co-authored-by: openhands <openhands@all-hands.dev>
The public copy had diverged: a ranked findings block, defensive dict
access, per-pod init-container maths and a Pending-projection fix. Fixes
belong in the source skill where they can be tested against real bundles,
so the script is now byte-identical and the docs describe what it does
rather than what the fork did.

Where the script is known to be wrong -- init containers added to the
regular sum, allocatable read from the first node -- the guide says so
and tells the reader how to redo the sum by hand.

Node roles are documented for both live clusters and bundles, since
sandboxes only schedule onto nodes labelled openhands.dev/sandbox and an
unschedulable sandbox otherwise reads as a capacity problem.

Co-authored-by: openhands <openhands@all-hands.dev>
The guide had grown a running commentary on how many nodes an install
has. It reads as a caveat about a configuration nobody is running, and
it dates the skill against a product that is still moving.

Scheduling constraints are worth covering on their own terms: a Pending
pod with a nodeSelector the cluster cannot satisfy is unschedulable
whatever the node count, and the allocation numbers do not explain it.
That guidance stays, phrased around the constraint rather than the
topology.

The allocation caveat keeps the init-container defect, which affects any
pod with init steps, and drops the first-node-allocatable one, which
cannot show up on the installs this skill is for.

Co-authored-by: openhands <openhands@all-hands.dev>
…uidance

Three gaps against the skill's requirements, all in prose so the triage
script stays a verbatim copy of its source.

The script reports observations and deliberately does not rank them, so
the ranking has to live somewhere. A new subsection walks the output in
the order a finding is likely to be a cause rather than a side effect,
and records two ways the analyzer summary misleads: warn-severity
entries print under a FAIL heading, and per-object analyzers show one
arbitrary example for the whole family.

Recovery said to verify at the end, which is where a half-applied fix
has already buried the evidence. It now validates between steps.

Log triage was written against files, but users paste excerpts. The
method transfers unchanged; the hazards do not, so the two that matter
are named — an excerpt is a selection made by someone with a theory,
and it arrives without the container name that gives it meaning.

Co-authored-by: openhands <openhands@all-hands.dev>
@aivong-openhands

Copy link
Copy Markdown
Contributor

Closing three gaps against the requirements

An audit against the original requirements turned up three things the skill did not do. All three are addressed in prose — bundle_triage.py is unchanged and still byte-identical to its source.

Parse and summarize the bundle to highlight the most likely root cause

The script reports observations and deliberately stops short of ranking them, so the ranking needs to live in the guide. A new Summarizing the Bundle: Most Likely Root Cause subsection walks the output in the order a finding is likely to be a cause rather than a side effect: place the symptom in time first, read analysis.json, then rank live crash loops and OOM above pods that never started, above Running-but-not-Ready, above node conditions, above resource pressure — which is usually a consequence.

Two ways the analyzer summary misleads are worth having written down, because both invite a confident wrong answer:

  • Everything non-passing prints under a FAIL heading, warn severity included. Some of those are advisory. Check the severity in analysis.json before calling one a failure to a customer.
  • Per-object analyzers are collapsed into families with a single example. [x12] means twelve objects affected and the line shown is an arbitrary one of them, not the worst.

Two further habits the section asks for: prefer the cause nearest the symptom, since a failed migrate-db init container and the app pod stuck behind it are one finding rather than two; and when the objects explain nothing, say so as a result — it locates the cause in the application logs, the network path, or outside the cluster, which is more useful than reporting that the bundle looked healthy. The output is a ranked shortlist with its evidence, which drops into the Likely Root Cause field of the handoff template.

Validate each recovery step before proceeding

The workflow verified once, at the end. That is the point at which a half-applied fix has already buried the evidence that would have told you the first step failed. Recovery now states what a step will change and what to expect, checks it before the next one, and stops to re-diagnose when the check disagrees. Destructive steps — restarts, rollbacks, config changes — need the user's agreement, and get recorded as they happen so the handoff can say what was touched.

Raw log output pasted into the conversation

Log triage was written against files, which is not how logs usually arrive. The three moves transfer to pasted text unchanged; the hazards do not, so the two that matter are named. A pasted excerpt is a selection, made by someone who already had a theory — the cause often sits in the lines just before what you were handed, so ask for the surrounding context rather than reasoning from the fragment. And it comes without a filename, so the container that produced it is unknown: the same stack trace from an init container and from the main container mean different things. The diagnostic workflow now routes all three input types — described symptom, bundle, pasted logs — from the top of SKILL.md.

Full suite: 786 passed. bundle_triage.py unmodified.


This comment was created by an AI agent (OpenHands) on behalf of @ai.vong.

@aivong-openhands
aivong-openhands marked this pull request as ready for review August 31, 2026 22:01
@all-hands-bot

Copy link
Copy Markdown
Contributor

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

This is an automated check - no AI was used to generate this comment.

The Keycloak health check invoked 'kc.sh health --metrics'. No such
subcommand exists — Keycloak serves health on the management port
(9000) at /health/ready, and only when health is enabled, so a 404 there
means the feature is off rather than the server being sick. Replaced
with a port-forward and a request to the documented endpoint.

The database connectivity check referenced $DB_HOST and $DB_PORT,
which are defined nowhere. Inside single quotes they expanded in the
pod, where they are not set either, so nc ran with empty arguments and
reported a failure unrelated to the database. It now reads the pod's
own configuration and then reads the error out of the log, which needs
no tooling inside the container.

Co-authored-by: openhands <openhands@all-hands.dev>
The health check went straight to a port-forward, which buries the
question a support engineer actually has: can users reach Keycloak. The
ingress answers that, and exercises DNS, TLS, and routing on the way, so
a failure there localises the fault to the path rather than the server.
It now leads with a request for realm metadata over the ingress.

Health is the exception and is worth explaining rather than asserting.
Since Keycloak 25 the /health endpoints live on a separate management
interface on port 9000, which exists so that health and metrics stay off
the public route; the ingress fronts the main HTTP port and does not
carry them. So health means going to the pod — by exec where the image
has curl, by port-forward where it does not.

Separately, KEYCLOAK_URL came from a jsonpath that returns a bare host.
Without a scheme curl assumes http://, so every admin-token check
against a TLS-fronted Keycloak was measuring a redirect.

Co-authored-by: openhands <openhands@all-hands.dev>
Comment thread skills/openhands-enterprise-troubleshooting/SKILL.md Outdated
The identifiers in diagnostics.md were written from assumption rather than
from a real install. Validating against five embedded-cluster bundles found
ten claims that match nothing in any of them, each of which fails as a
silent all-clear: a wrong selector prints "No resources found" and exits 0,
so an agent reads a wrong query as a healthy component.

Keycloak is in the openhands namespace as statefulset/keycloak (pod
keycloak-0, selector app.kubernetes.io/name=keycloak), not a keycloak
namespace with a Deployment. The app deployment is openhands; agent-server
is the container image name, not a workload. LLM wiring is in
openhands-litellm-config with litellm-env-secrets/openhands-env-secrets,
not llm-config/llm-credentials. The TLS secret is named in the ingress
spec rather than carrying an app=ingress-tls label. The replicated
namespace is kURL-era and absent from embedded-cluster installs.

Also stop decoding live credentials into the shell. The LLM and Keycloak
sections previously assigned a working API key and admin password to shell
variables, which writes them into terminal history and any agent
transcript. Read key names and byte lengths instead, and run credential
checks inside the pod that already holds the secret.

Co-authored-by: openhands <openhands@all-hands.dev>
… live validation

Validating against a live v0.58.0 / k0s 1.36.1 instance refuted several
claims that five support bundles could not settle, because a bundle records
object state and cannot show whether a command runs.

The port-9000 health check was wrong. These installs ship Bitnami Keycloak,
which does not expose the management interface upstream Keycloak 25+ serves
on 9000: only 8080 and 7800 are open, :9000 refuses the connection, and
:8080/health returns 404. The realm endpoint is the only reliable liveness
signal, so the guidance now says to read the exposed ports and image first
and treat health endpoints as distribution-specific. An earlier note here
claimed 9000 was served despite not being declared in the pod spec; that
rationalised away the missing containerPort instead of treating it as the
evidence it was.

The keycloak-admin secret claim was stated as an absolute from bundle data
that showed variance. Some installs carry it mounted via
KC_BOOTSTRAP_ADMIN_PASSWORD_FILE, others expose only keycloak-realm, so the
guidance now says to check which is present.

The app image ships no nslookup or dig, so the DNS check used getent, which
is part of libc. du runs as non-root and exits 1 with permission noise even
when it succeeded. kubectl is not on the PATH at all: openhands shell needs
a TTY and so fails for non-interactive agents, which now have sudo k0s
kubectl as the documented fallback.

Co-authored-by: openhands <openhands@all-hands.dev>
…ive validation

A third validation round against the live v0.58.0 instance ran the commands
from the previous two commits. Most held; these did not.

The LLM endpoint test was broken. LLM_API_KEY and LLM_BASE_URL are key names
inside openhands-env-secrets, not variables in the app pod, which exposes
LITE_LLM_API_KEY and LITE_LLM_API_URL instead. The documented request
expanded to an empty host and could never have worked. The section now lists
the pod environment first and explains that an empty variable in the URL
means a secret key name was used in place of a pod variable name.

Bitnami Keycloak wires its database through KEYCLOAK_DATABASE_* rather than
upstream KC_DB_*, so the documented grep returned nothing and read as an
unconfigured database.

The Keycloak realm check selected the ingress by resource name. That name is
correct on current builds but incidental; selecting on the auth host is
stable across renames.

Two error-table rows used wording that appears in no real log across six
bundles: "certificate hostname mismatch" and "connection timeout" are
descriptions rather than the strings OpenSSL and Go actually emit.

The 120-second sandbox timeout was not observable on a real install; the
dominant runtime-api timeout is a configurable 15s. Quoting a fixed number
sends people after the wrong timeout, so the symptom now says to read the
value.

Stateful workload capture turns out to be bundle-spec-dependent: the newest
bundle carries statefulsets/ while five older ones omit it, so an absent
directory means not collected rather than not deployed.

Co-authored-by: openhands <openhands@all-hands.dev>
Upgrades are driven from the Admin Console, and validating this section
would mean deliberately breaking an upgrade on a live install, so it will
not be tested. Saying so in the text is better than letting it read like
the validated sections around it: an agent cannot otherwise tell that these
commands carry less evidence than the ones next to them.

Also stop naming kurl-proxy-kotsadm when checking console access. That is
the kURL-era service name and may not exist on Embedded Cluster, the same
assumption a reviewer caught for the replicated namespace. Listing the
services shows what is really there.

Co-authored-by: openhands <openhands@all-hands.dev>
The section keeps the operational guidance — upgrades and rollbacks are
driven from the Admin Console because KOTS owns the deployment state — and
drops the note about what has not been tested. Which commands happen to
have been exercised is a fact about how the skill was written, not
something a reader can act on.

Co-authored-by: openhands <openhands@all-hands.dev>
Both upgrade warnings described the dangerous action before forbidding it —
driving a rollback from the CLI, editing Helm releases, deleting resources
directly. Naming a path plants it, and a reader halfway through a broken
upgrade is exactly who will remember the technique rather than the
prohibition. Both now say only where the work belongs: the Admin Console
owns deployment state, and an install that cannot be recovered there is a
support bundle and an escalation.

Co-authored-by: openhands <openhands@all-hands.dev>
The condition read "if rollback is unavailable and the install is broken",
which invites the reader to work out whether rollback is available before
escalating. A broken install is reason enough.

Co-authored-by: openhands <openhands@all-hands.dev>
Fault injection settled a question earlier rounds could not. With Keycloak
scaled to zero, the documented first step returns "No resources found" and
exits 0 — byte-identical to what a wrong selector returns on a healthy
install. Three real selector bugs had already been found that way, so the
same output has meant both things in this file. Component checks now lead
with the workload READY count, which exists either way: 0/0 is scaled down,
0/1 is failing, and a NotFound error means the name was wrong. SKILL.md
states the principle up front.

Two of my own claims were wrong and are corrected. The kotsadm chart really
does use bare app= labels, so those selectors were right all along; its
app.kubernetes.io/name is admin-console, and the note now says so to stop a
blind "fix" from breaking them. kurl-proxy-kotsadm is also still present on
Embedded Cluster, serving 8800:30000 — the name is a kURL leftover but the
service is not gone.

The preflight grep failed silently for a reason unrelated to selectors:
preflight logs once at deploy and had scrolled past --tail=200 on an install
less than an hour old. Dropped the tail and said what silence means.

Provider checks were narrow rather than broken: the bare-prefix grep misses
OPENHANDS_*_SERVICE_CLS and OH_WEB_CLIENT_PROVIDERS_CONFIGURED, and the
secret example assumed GitHub on installs that have GitLab. Both now follow
what is actually present. Also noted that zero runtime- pods is normal on an
idle install, since the doc used that count as a fault signal.

Co-authored-by: openhands <openhands@all-hands.dev>
Round 5 covered the two sections no earlier round reached, and the file
descriptor check was measuring three things that were not the server. ulimit
-n reported the tester SSH shell (1024), fs/file-max the host ceiling, and
ls /proc/self/fd the handles of the ls process (4) — while the app itself
had a limit of 999999 and 17 in use. Every number looked plausible, which is
why this survived five rounds. The check now reads /proc/1/limits and
/proc/1/fd inside the container, and the fixes table points at the same.

Certificates had the same shape of error a layer up. The public host does not
resolve from the VM, so the documented s_client could not connect at all;
it now targets 127.0.0.1 with the name passed as SNI. More subtly, s_client
does no hostname verification by default, so a certificate for the wrong name
returns "Verify return code: 0 (ok)" — the one failure the command exists to
catch reads as healthy. Added -verify_hostname. The mismatch row also listed
a string openssl never prints, replaced with its real lowercase wording and
curl equivalent.

TLS terminates in the ingress controller namespace, so a :443 service search
in openhands finds nothing; the search is now cluster-wide. A blank
secretName on a runtime-<id> ingress is normal rather than a fault, and now
says so.

App logs are structured JSON on a severity key, and Python tops out at
CRITICAL, so the documented FATAL grep could never match. Left the FATAL in
the Keycloak database check, where Quarkus and Postgres do emit it.

kubectl debug node works but needs a TTY an agent shell may not have; on a
single-node install df -h / answers the same question.

Co-authored-by: openhands <openhands@all-hands.dev>
The round-5 fixes replaced commands that provably failed with commands
nobody has run, and two of them fail the same silent way if wrong.

The cert check now hardcoded 127.0.0.1:443, but the report that prompted it
also said traefik serves 443 through a NodePort — which need not bind 443 on
the host at all. Rather than swap one guess for another, find the listening
port with ss first and use that.

The severity grep matched "severity":"ERROR" with no space after the colon.
If the log library emits a space it never matches, which is the never-
matching-string defect this file has now had three times. Made the space
optional and added the one-line print so the real format can be read off
instead of assumed.

Co-authored-by: openhands <openhands@all-hands.dev>
It was a testing diary rather than analysis guidance: three lists recording
which claims had been checked against a real bundle, which were fixture-only,
and which were unverified. A reader with a bundle in front of them cannot act
on any of it, and it dates immediately.

The one caveat in it that was operational — that --section alloc over-charges
pods with init steps, so its percentages need redoing by hand — already
appears where the allocation arithmetic is explained. Same for the mtime
trap. Nothing actionable is lost.

Co-authored-by: openhands <openhands@all-hands.dev>
…lone

My previous commit replaced a working command with a dead end. It told the
reader to find the TLS port with ss -lntp, but traefik publishes 443 as a
NodePort, and kube-proxy in iptables mode serves those through DNAT rules
rather than a listening socket — so ss matches nothing however healthy the
install is, and netstat is not present as a fallback. The port comes off the
ingress controller Service instead, which is where the neighbouring command
was already looking. 127.0.0.1:443 was right before I changed it.

-verify_hostname survives validation and stays: with the correct name it
returns 0 (ok) and with a wrong one 62 (hostname mismatch), so it does detect
the mismatch that plain s_client reports as healthy. It needs a bare
hostname, though — a leading https:// fails the check against a good
certificate, so the doc now says so and names both expected codes.

The severity filter needed a caveat I would not have predicted: handled
exceptions are logged at INFO with the exception name in the message, so
NoCredentialsError appears in the logs while every severity field says INFO.
A clean severity search therefore does not mean a quiet app. Added a text
search alongside it, and extended the empty-result principle in SKILL.md to
cover log filters as well as selectors.

Co-authored-by: openhands <openhands@all-hands.dev>
I generalised it from five NoCredentialsError lines in one healthy install,
which is most likely an optional integration going unconfigured — handled,
expected, and correctly INFO. That is not evidence that error-shaped events
are routinely logged at INFO, and writing it that way told readers to distrust
the severity field on the strength of one narrow sample. An error the app
actually hits should be logged at ERROR; if it is not, that is a bug to fix
rather than a search to work around.

The generic text grep further down the section already covers looking for
Exception and Traceback, so nothing operational is lost.

Co-authored-by: openhands <openhands@all-hands.dev>
@aivong-openhands
aivong-openhands requested review from aivong-openhands and removed request for aivong-openhands September 1, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skill for diagnosing self-hosted OpenHands Enterprise installs

4 participants