@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,532 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Triage a Replicated support bundle offline.
|
|
3
|
+
|
|
4
|
+
Reconstructs the kubectl commands you would normally run against a live cluster:
|
|
5
|
+
|
|
6
|
+
kubectl get pods -n <ns> -o wide
|
|
7
|
+
kubectl top pods -n <ns>
|
|
8
|
+
kubectl describe node (capacity, conditions, allocated resources)
|
|
9
|
+
kubectl get events -n <ns> --sort-by=.lastTimestamp
|
|
10
|
+
|
|
11
|
+
Plus an OOM / restart scan across every namespace and the bundle's own
|
|
12
|
+
pre-computed analyzer verdicts.
|
|
13
|
+
|
|
14
|
+
Standard library only. Usage:
|
|
15
|
+
|
|
16
|
+
python3 bundle_triage.py <bundle-dir>
|
|
17
|
+
python3 bundle_triage.py <bundle-dir> --namespace openhands --section pods
|
|
18
|
+
python3 bundle_triage.py <bundle-dir> --expand-runtimes
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import collections
|
|
25
|
+
import datetime as dt
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
# Sandbox pods are created one-per-conversation and can number in the hundreds.
|
|
32
|
+
# Collapse them to a summary unless --expand-runtimes is passed.
|
|
33
|
+
RUNTIME_PREFIX = "runtime-"
|
|
34
|
+
|
|
35
|
+
SECTIONS = ("meta", "analyzers", "pods", "restarts", "top", "alloc", "events")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------
|
|
39
|
+
# helpers
|
|
40
|
+
# --------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def load(path: Path):
|
|
43
|
+
"""Read a JSON file, returning None if absent or unparseable."""
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(path.read_text())
|
|
46
|
+
except (OSError, ValueError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def items(path: Path) -> list:
|
|
51
|
+
"""Return .items from a Kubernetes List file. Handles `"items": null`."""
|
|
52
|
+
data = load(path)
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
return []
|
|
55
|
+
return data.get("items") or []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_ts(value: str | None) -> dt.datetime | None:
|
|
59
|
+
if not value:
|
|
60
|
+
return None
|
|
61
|
+
for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S.%fZ"):
|
|
62
|
+
try:
|
|
63
|
+
return dt.datetime.strptime(value, fmt).replace(tzinfo=dt.timezone.utc)
|
|
64
|
+
except ValueError:
|
|
65
|
+
continue
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
QUANTITY = re.compile(r"^(\d+(?:\.\d+)?)([a-zA-Z]*)$")
|
|
70
|
+
MULTIPLIER = {
|
|
71
|
+
"": 1, "n": 1e-9, "u": 1e-6, "m": 1e-3,
|
|
72
|
+
"k": 1e3, "M": 1e6, "G": 1e9, "T": 1e12, "P": 1e15,
|
|
73
|
+
"Ki": 2 ** 10, "Mi": 2 ** 20, "Gi": 2 ** 30, "Ti": 2 ** 40, "Pi": 2 ** 50,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def quantity(value) -> float:
|
|
78
|
+
"""Parse a Kubernetes resource quantity ('100m', '12Gi', '131774212Ki')."""
|
|
79
|
+
if value is None:
|
|
80
|
+
return 0.0
|
|
81
|
+
match = QUANTITY.match(str(value))
|
|
82
|
+
if not match:
|
|
83
|
+
return 0.0
|
|
84
|
+
return float(match.group(1)) * MULTIPLIER.get(match.group(2), 1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def gib(n: float) -> str:
|
|
88
|
+
return f"{n / 2 ** 30:.1f}Gi"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def capture_time(root: Path) -> dt.datetime:
|
|
92
|
+
"""Best-effort 'now' for the bundle.
|
|
93
|
+
|
|
94
|
+
File mtimes are extraction-time and local, so they are never used. Prefer
|
|
95
|
+
the directory name stamped by the collector, then fall back to the newest
|
|
96
|
+
timestamp in the kubelet metrics.
|
|
97
|
+
"""
|
|
98
|
+
match = re.search(r"(\d{4})-(\d{2})-(\d{2})T(\d{2})[_:](\d{2})[_:](\d{2})", root.name)
|
|
99
|
+
if match:
|
|
100
|
+
y, mo, d, h, mi, s = (int(g) for g in match.groups())
|
|
101
|
+
return dt.datetime(y, mo, d, h, mi, s, tzinfo=dt.timezone.utc)
|
|
102
|
+
|
|
103
|
+
newest = None
|
|
104
|
+
for path in (root / "node-metrics").glob("*.json"):
|
|
105
|
+
blob = path.read_text()
|
|
106
|
+
for raw in re.findall(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", blob):
|
|
107
|
+
ts = parse_ts(raw)
|
|
108
|
+
if ts and (newest is None or ts > newest):
|
|
109
|
+
newest = ts
|
|
110
|
+
return newest or dt.datetime.now(dt.timezone.utc)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def age(created: str | None, now: dt.datetime) -> str:
|
|
114
|
+
ts = parse_ts(created)
|
|
115
|
+
if not ts:
|
|
116
|
+
return "?"
|
|
117
|
+
secs = max(int((now - ts).total_seconds()), 0)
|
|
118
|
+
days, rem = divmod(secs, 86400)
|
|
119
|
+
hours, rem = divmod(rem, 3600)
|
|
120
|
+
mins, s = divmod(rem, 60)
|
|
121
|
+
if days:
|
|
122
|
+
return f"{days}d{hours}h"
|
|
123
|
+
if hours:
|
|
124
|
+
return f"{hours}h{mins}m"
|
|
125
|
+
if mins:
|
|
126
|
+
return f"{mins}m{s}s"
|
|
127
|
+
return f"{s}s"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def pod_status(pod: dict) -> str:
|
|
131
|
+
"""Mirror kubectl's STATUS column, which is not simply .status.phase."""
|
|
132
|
+
status = pod.get("status", {})
|
|
133
|
+
reason = status.get("reason") or status.get("phase", "Unknown")
|
|
134
|
+
|
|
135
|
+
initializing = False
|
|
136
|
+
for cs in status.get("initContainerStatuses") or []:
|
|
137
|
+
state = cs.get("state", {})
|
|
138
|
+
term = state.get("terminated")
|
|
139
|
+
if term and term.get("exitCode") == 0:
|
|
140
|
+
continue
|
|
141
|
+
if term:
|
|
142
|
+
reason = f"Init:{term.get('reason') or 'ExitCode:' + str(term.get('exitCode'))}"
|
|
143
|
+
initializing = True
|
|
144
|
+
elif state.get("waiting", {}).get("reason") not in (None, "PodInitializing"):
|
|
145
|
+
reason = "Init:" + state["waiting"]["reason"]
|
|
146
|
+
initializing = True
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
if not initializing:
|
|
150
|
+
for cs in reversed(status.get("containerStatuses") or []):
|
|
151
|
+
state = cs.get("state", {})
|
|
152
|
+
if state.get("waiting", {}).get("reason"):
|
|
153
|
+
reason = state["waiting"]["reason"]
|
|
154
|
+
elif state.get("terminated", {}).get("reason"):
|
|
155
|
+
reason = state["terminated"]["reason"]
|
|
156
|
+
elif state.get("terminated"):
|
|
157
|
+
reason = f"ExitCode:{state['terminated'].get('exitCode')}"
|
|
158
|
+
|
|
159
|
+
if pod["metadata"].get("deletionTimestamp"):
|
|
160
|
+
reason = "Terminating"
|
|
161
|
+
return reason
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def restarts(pod: dict) -> int:
|
|
165
|
+
return sum(c.get("restartCount", 0) for c in pod["status"].get("containerStatuses") or [])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def ready(pod: dict) -> str:
|
|
169
|
+
statuses = pod["status"].get("containerStatuses") or []
|
|
170
|
+
return f"{sum(1 for c in statuses if c.get('ready'))}/{len(pod['spec'].get('containers', []))}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def all_pods(root: Path) -> list[tuple[str, dict]]:
|
|
174
|
+
out = []
|
|
175
|
+
for path in sorted((root / "cluster-resources" / "pods").glob("*.json")):
|
|
176
|
+
for pod in items(path):
|
|
177
|
+
out.append((path.stem, pod))
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def header(title: str) -> None:
|
|
182
|
+
print()
|
|
183
|
+
print("=" * 78)
|
|
184
|
+
print(title)
|
|
185
|
+
print("=" * 78)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --------------------------------------------------------------------------
|
|
189
|
+
# sections
|
|
190
|
+
# --------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def section_meta(root: Path, now: dt.datetime) -> None:
|
|
193
|
+
header("BUNDLE METADATA")
|
|
194
|
+
print(f" bundle path : {root}")
|
|
195
|
+
print(f" captured at : {now.isoformat()} (all ages below are relative to this)")
|
|
196
|
+
|
|
197
|
+
version = load(root / "cluster-info" / "cluster_version.json")
|
|
198
|
+
if version:
|
|
199
|
+
print(f" kubernetes : {version.get('string')}")
|
|
200
|
+
|
|
201
|
+
app_info = load(root / "kots" / "admin_console" / "app-info.json")
|
|
202
|
+
if app_info:
|
|
203
|
+
down = app_info.get("downstream", {})
|
|
204
|
+
print(f" app status : {app_info.get('app_status')}")
|
|
205
|
+
print(f" distribution : {app_info.get('k8s_distribution')} "
|
|
206
|
+
f"{app_info.get('embedded_cluster_version', '')}".rstrip())
|
|
207
|
+
print(f" KOTS : {app_info.get('user_agent')}")
|
|
208
|
+
print(f" channel : {down.get('channel_name')} sequence={down.get('sequence')} "
|
|
209
|
+
f"status={down.get('status')} source={down.get('source')!r}")
|
|
210
|
+
print(f" preflights : {down.get('preflight_state')}")
|
|
211
|
+
|
|
212
|
+
for node in items(root / "cluster-resources" / "nodes.json") or (
|
|
213
|
+
load(root / "cluster-resources" / "nodes.json") or {}
|
|
214
|
+
).get("items") or []:
|
|
215
|
+
status = node["status"]
|
|
216
|
+
cap = status["capacity"]
|
|
217
|
+
info = status["nodeInfo"]
|
|
218
|
+
print()
|
|
219
|
+
print(f" NODE {node['metadata']['name']}")
|
|
220
|
+
print(f" capacity : cpu={cap.get('cpu')} memory={gib(quantity(cap.get('memory')))} "
|
|
221
|
+
f"pods={cap.get('pods')} ephemeral={gib(quantity(cap.get('ephemeral-storage')))}")
|
|
222
|
+
print(f" os/runtime : {info.get('osImage')} | {info.get('containerRuntimeVersion')} "
|
|
223
|
+
f"| kubelet {info.get('kubeletVersion')}")
|
|
224
|
+
for cond in status.get("conditions", []):
|
|
225
|
+
bad = (cond["type"] == "Ready" and cond["status"] != "True") or \
|
|
226
|
+
(cond["type"] != "Ready" and cond["status"] == "True")
|
|
227
|
+
print(f" {'!!' if bad else ' '} {cond['type']:<18} {cond['status']:<6} "
|
|
228
|
+
f"{cond.get('reason', '')} (since {cond.get('lastTransitionTime')})")
|
|
229
|
+
if node["spec"].get("taints"):
|
|
230
|
+
print(f" taints : {json.dumps(node['spec']['taints'])}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def section_analyzers(root: Path) -> None:
|
|
234
|
+
analysis = load(root / "analysis.json")
|
|
235
|
+
if not analysis:
|
|
236
|
+
return
|
|
237
|
+
header("ANALYZER VERDICTS (analysis.json — pre-computed by the collector)")
|
|
238
|
+
by_sev = collections.Counter(a.get("severity") for a in analysis)
|
|
239
|
+
print(f" {len(analysis)} analyzers: {dict(by_sev)}")
|
|
240
|
+
|
|
241
|
+
# Per-sandbox analyzers are generated one-per-pod and otherwise drown out
|
|
242
|
+
# everything else. Collapse the object-specific middle of the dotted name.
|
|
243
|
+
def family(name: str) -> str:
|
|
244
|
+
parts = name.split(".")
|
|
245
|
+
return ".".join(parts[:2] + ["*"] + parts[-1:]) if len(parts) > 3 else name
|
|
246
|
+
|
|
247
|
+
grouped: dict[str, list] = collections.defaultdict(list)
|
|
248
|
+
for entry in analysis:
|
|
249
|
+
if entry.get("severity") not in ("debug", "info"):
|
|
250
|
+
grouped[family(entry["name"])].append(entry)
|
|
251
|
+
|
|
252
|
+
if grouped:
|
|
253
|
+
print()
|
|
254
|
+
for name, entries in sorted(grouped.items(), key=lambda kv: -len(kv[1])):
|
|
255
|
+
count = f" [x{len(entries)}]" if len(entries) > 1 else ""
|
|
256
|
+
print(f" FAIL {name}{count}")
|
|
257
|
+
print(f" e.g. {entries[0]['insight'].get('detail', '')[:110]}")
|
|
258
|
+
|
|
259
|
+
passed = [e for e in analysis if e.get("severity") in ("debug", "info")]
|
|
260
|
+
if passed:
|
|
261
|
+
print(f"\n {len(passed)} analyzers passed. Notable:")
|
|
262
|
+
for entry in passed:
|
|
263
|
+
if re.search(r"oom|resource|storage|node|status", entry["name"], re.I):
|
|
264
|
+
print(f" OK {entry['name']:<38} {entry['insight'].get('detail', '')[:70]}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def section_pods(root: Path, ns: str, now: dt.datetime, expand: bool) -> None:
|
|
268
|
+
pods = [p for n, p in all_pods(root) if n == ns]
|
|
269
|
+
if not pods:
|
|
270
|
+
print(f"\n(no pods captured for namespace {ns!r})")
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
platform = [p for p in pods if not p["metadata"]["name"].startswith(RUNTIME_PREFIX)]
|
|
274
|
+
sandboxes = [p for p in pods if p["metadata"]["name"].startswith(RUNTIME_PREFIX)]
|
|
275
|
+
|
|
276
|
+
header(f"kubectl get pods -n {ns} -o wide ({len(pods)} pods)")
|
|
277
|
+
shown = pods if expand else platform
|
|
278
|
+
print(f"{'NAME':<56}{'READY':<7}{'STATUS':<14}{'RESTARTS':>9}{'AGE':>9} NODE")
|
|
279
|
+
for pod in sorted(shown, key=lambda p: p["metadata"]["name"]):
|
|
280
|
+
meta, spec = pod["metadata"], pod["spec"]
|
|
281
|
+
print(f"{meta['name']:<56}{ready(pod):<7}{pod_status(pod):<14}"
|
|
282
|
+
f"{restarts(pod):>9}{age(meta.get('creationTimestamp'), now):>9} "
|
|
283
|
+
f"{spec.get('nodeName') or '<unscheduled>'}")
|
|
284
|
+
|
|
285
|
+
if sandboxes and not expand:
|
|
286
|
+
print()
|
|
287
|
+
print(f" + {len(sandboxes)} {RUNTIME_PREFIX}* sandbox pods (use --expand-runtimes to list):")
|
|
288
|
+
print(f" status : {dict(collections.Counter(pod_status(p) for p in sandboxes))}")
|
|
289
|
+
print(f" ready : {dict(collections.Counter(ready(p) for p in sandboxes))}")
|
|
290
|
+
churn = [p for p in sandboxes if restarts(p)]
|
|
291
|
+
print(f" with restarts>0 : {len(churn)}")
|
|
292
|
+
|
|
293
|
+
unscheduled = [p for p in pods if not p["spec"].get("nodeName")]
|
|
294
|
+
if unscheduled:
|
|
295
|
+
print()
|
|
296
|
+
print(f" UNSCHEDULED PODS: {len(unscheduled)} — scheduler messages, deduped:")
|
|
297
|
+
reasons = collections.Counter()
|
|
298
|
+
for pod in unscheduled:
|
|
299
|
+
for cond in pod["status"].get("conditions") or []:
|
|
300
|
+
if cond["type"] == "PodScheduled" and cond["status"] != "True":
|
|
301
|
+
# Strip pod-specific names so identical failures collapse.
|
|
302
|
+
msg = re.sub(r'"[^"]+"', '"<name>"', cond.get("message", ""))
|
|
303
|
+
reasons[(cond.get("reason"), msg)] += 1
|
|
304
|
+
for (reason, msg), count in reasons.most_common(10):
|
|
305
|
+
print(f" [{count:>3}x] {reason}: {msg}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def section_restarts(root: Path, now: dt.datetime) -> None:
|
|
309
|
+
header("RESTART / TERMINATION SCAN (all namespaces)")
|
|
310
|
+
rows = []
|
|
311
|
+
for ns, pod in all_pods(root):
|
|
312
|
+
statuses = (pod["status"].get("containerStatuses") or []) + \
|
|
313
|
+
(pod["status"].get("initContainerStatuses") or [])
|
|
314
|
+
for cs in statuses:
|
|
315
|
+
for key in ("lastState", "state"):
|
|
316
|
+
term = (cs.get(key) or {}).get("terminated")
|
|
317
|
+
if term and term.get("reason") not in (None, "Completed"):
|
|
318
|
+
rows.append((ns, pod["metadata"]["name"], cs["name"], term.get("reason"),
|
|
319
|
+
term.get("exitCode"), cs.get("restartCount", 0),
|
|
320
|
+
term.get("finishedAt")))
|
|
321
|
+
|
|
322
|
+
oom = [r for r in rows if r[3] == "OOMKilled"]
|
|
323
|
+
print(f" OOMKilled containers: {len(oom)}")
|
|
324
|
+
for row in oom:
|
|
325
|
+
print(f" !! {row[0]}/{row[1]} c={row[2]} restarts={row[5]} at={row[6]}")
|
|
326
|
+
if not oom:
|
|
327
|
+
print(" (no OOMKilled anywhere in the bundle)")
|
|
328
|
+
|
|
329
|
+
def fit(value, width):
|
|
330
|
+
text = str(value)
|
|
331
|
+
return text if len(text) <= width else text[:width - 1] + "…"
|
|
332
|
+
|
|
333
|
+
print()
|
|
334
|
+
print(f" Other abnormal terminations: {len(rows) - len(oom)}")
|
|
335
|
+
print(f" {'NS':<17}{'POD':<45}{'CONTAINER':<23}{'REASON':<11}{'EXIT':>5}{'RST':>5} FINISHED")
|
|
336
|
+
for ns, pod, container, reason, code, count, finished in sorted(rows, key=lambda r: -r[5]):
|
|
337
|
+
if reason == "OOMKilled":
|
|
338
|
+
continue
|
|
339
|
+
print(f" {fit(ns, 16):<17}{fit(pod, 44):<45}{fit(container, 22):<23}"
|
|
340
|
+
f"{fit(reason, 10):<11}{str(code):>5}{count:>5} "
|
|
341
|
+
f"{finished} ({age(finished, now)} ago)")
|
|
342
|
+
|
|
343
|
+
# Simultaneous terminations across unrelated namespaces == host restart,
|
|
344
|
+
# not per-workload failure. Surface that so it isn't misread.
|
|
345
|
+
stamps = collections.Counter(r[6] for r in rows if r[6])
|
|
346
|
+
for stamp, count in stamps.most_common(3):
|
|
347
|
+
if count >= 5:
|
|
348
|
+
print()
|
|
349
|
+
print(f" NOTE: {count} containers last terminated at the same instant ({stamp}).")
|
|
350
|
+
print(" Simultaneous exits across unrelated namespaces indicate a node")
|
|
351
|
+
print(" reboot or kubelet restart, not independent workload crashes.")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def section_top(root: Path, ns: str) -> None:
|
|
355
|
+
metrics_files = sorted((root / "node-metrics").glob("*.json"))
|
|
356
|
+
if not metrics_files:
|
|
357
|
+
print("\n(no node-metrics/ — `kubectl top` cannot be reconstructed)")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
for path in metrics_files:
|
|
361
|
+
data = load(path)
|
|
362
|
+
if not data:
|
|
363
|
+
continue
|
|
364
|
+
node = data.get("node", {})
|
|
365
|
+
header(f"NODE USAGE + kubectl top pods ({path.stem})")
|
|
366
|
+
cpu = node.get("cpu", {}).get("usageNanoCores", 0) / 1e9
|
|
367
|
+
mem = node.get("memory", {})
|
|
368
|
+
print(f" sampled at : {node.get('cpu', {}).get('time')}")
|
|
369
|
+
print(f" node booted : {node.get('startTime')}")
|
|
370
|
+
print(f" cpu used : {cpu:.2f} cores")
|
|
371
|
+
print(f" mem workingSet: {gib(mem.get('workingSetBytes', 0))} "
|
|
372
|
+
f"available: {gib(mem.get('availableBytes', 0))}")
|
|
373
|
+
fs = node.get("fs") or {}
|
|
374
|
+
if fs:
|
|
375
|
+
used, cap = fs.get("usedBytes", 0), fs.get("capacityBytes", 1)
|
|
376
|
+
print(f" nodefs : {gib(used)} / {gib(cap)} ({used / cap * 100:.1f}% used)")
|
|
377
|
+
|
|
378
|
+
rows = []
|
|
379
|
+
for pod in data.get("pods", []):
|
|
380
|
+
ref = pod["podRef"]
|
|
381
|
+
rows.append((
|
|
382
|
+
ref["namespace"], ref["name"],
|
|
383
|
+
(pod.get("cpu") or {}).get("usageNanoCores", 0),
|
|
384
|
+
(pod.get("memory") or {}).get("workingSetBytes", 0),
|
|
385
|
+
(pod.get("ephemeral-storage") or {}).get("usedBytes", 0),
|
|
386
|
+
))
|
|
387
|
+
rows.sort(key=lambda r: -r[3])
|
|
388
|
+
|
|
389
|
+
print()
|
|
390
|
+
print(f" {'NAMESPACE':<16}{'NAME':<52}{'CPU':>9}{'MEMORY':>10}{'EPHEM':>10}")
|
|
391
|
+
for namespace, name, c, m, e in rows:
|
|
392
|
+
mark = " " if namespace == ns else " "
|
|
393
|
+
print(f"{mark}{namespace:<16}{name:<52}{c / 1e9:>9.3f}{m / 2 ** 20:>9.0f}Mi{e / 2 ** 20:>9.0f}Mi")
|
|
394
|
+
print(f"\n pods reported by kubelet: {len(rows)}"
|
|
395
|
+
f" (this counts only pods that actually started)")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def section_alloc(root: Path) -> None:
|
|
399
|
+
nodes = items(root / "cluster-resources" / "nodes.json")
|
|
400
|
+
if not nodes:
|
|
401
|
+
return
|
|
402
|
+
allocatable = nodes[0]["status"]["allocatable"]
|
|
403
|
+
|
|
404
|
+
scheduled = [(ns, p) for ns, p in all_pods(root)
|
|
405
|
+
if p["spec"].get("nodeName") and p["status"].get("phase") in ("Running", "Pending")]
|
|
406
|
+
pending = [(ns, p) for ns, p in all_pods(root) if p["status"].get("phase") == "Pending"]
|
|
407
|
+
|
|
408
|
+
def totals(pods):
|
|
409
|
+
acc = collections.defaultdict(float)
|
|
410
|
+
for _, pod in pods:
|
|
411
|
+
containers = pod["spec"].get("containers", []) + pod["spec"].get("initContainers", [])
|
|
412
|
+
for c in containers:
|
|
413
|
+
res = c.get("resources", {})
|
|
414
|
+
for key, value in (res.get("requests") or {}).items():
|
|
415
|
+
acc["req_" + key] += quantity(value)
|
|
416
|
+
for key, value in (res.get("limits") or {}).items():
|
|
417
|
+
acc["lim_" + key] += quantity(value)
|
|
418
|
+
return acc
|
|
419
|
+
|
|
420
|
+
used = totals(scheduled)
|
|
421
|
+
header("ALLOCATED RESOURCES (kubectl describe node → 'Allocated resources')")
|
|
422
|
+
print(f" {'RESOURCE':<20}{'REQUESTS':>14}{'%':>8}{'LIMITS':>14}{'%':>8}")
|
|
423
|
+
for key in ("cpu", "memory", "ephemeral-storage"):
|
|
424
|
+
cap = quantity(allocatable.get(key))
|
|
425
|
+
if not cap:
|
|
426
|
+
continue
|
|
427
|
+
req, lim = used["req_" + key], used["lim_" + key]
|
|
428
|
+
fmt = (lambda v: f"{v:.2f}") if key == "cpu" else gib
|
|
429
|
+
print(f" {key:<20}{fmt(req):>14}{req / cap * 100:>7.1f}%{fmt(lim):>14}{lim / cap * 100:>7.1f}%")
|
|
430
|
+
pod_cap = int(quantity(allocatable.get("pods")))
|
|
431
|
+
print(f" {'pods':<20}{len(scheduled):>14}{len(scheduled) / pod_cap * 100:>7.1f}%"
|
|
432
|
+
f" (capacity {pod_cap})")
|
|
433
|
+
print(f"\n allocatable: " + " ".join(
|
|
434
|
+
f"{k}={allocatable.get(k)}" for k in ("cpu", "memory", "ephemeral-storage", "pods")))
|
|
435
|
+
|
|
436
|
+
if pending:
|
|
437
|
+
extra = totals(pending)
|
|
438
|
+
print()
|
|
439
|
+
print(f" If the {len(pending)} Pending pods were also scheduled they would add:")
|
|
440
|
+
for key in ("cpu", "memory", "ephemeral-storage"):
|
|
441
|
+
cap = quantity(allocatable.get(key))
|
|
442
|
+
if not cap:
|
|
443
|
+
continue
|
|
444
|
+
req = extra["req_" + key]
|
|
445
|
+
total = used["req_" + key] + req
|
|
446
|
+
flag = " <-- EXCEEDS ALLOCATABLE" if total > cap else ""
|
|
447
|
+
fmt = (lambda v: f"{v:.2f}") if key == "cpu" else gib
|
|
448
|
+
print(f" {key:<20} +{fmt(req):<12} → {fmt(total)} of {fmt(cap)} requested{flag}")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def section_events(root: Path, ns: str, now: dt.datetime) -> None:
|
|
452
|
+
events = items(root / "cluster-resources" / "events" / f"{ns}.json")
|
|
453
|
+
header(f"kubectl get events -n {ns} --sort-by=.lastTimestamp ({len(events)} events)")
|
|
454
|
+
if not events:
|
|
455
|
+
print(" (none captured — an empty events file means the API server had no events")
|
|
456
|
+
print(" left in its TTL window, NOT that nothing happened)")
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
def stamp(e):
|
|
460
|
+
return (e.get("lastTimestamp") or e.get("eventTime")
|
|
461
|
+
or (e.get("series") or {}).get("lastObservedTime")
|
|
462
|
+
or e["metadata"].get("creationTimestamp"))
|
|
463
|
+
|
|
464
|
+
times = [t for t in (parse_ts(stamp(e)) for e in events) if t]
|
|
465
|
+
if times:
|
|
466
|
+
print(f" window covered: {min(times).isoformat()} → {max(times).isoformat()}"
|
|
467
|
+
f" ({max(times) - min(times)})")
|
|
468
|
+
print(" ANYTHING OLDER THAN THIS WINDOW IS NOT IN THE BUNDLE.")
|
|
469
|
+
|
|
470
|
+
print(f"\n reasons: {dict(collections.Counter(e.get('reason') for e in events).most_common(12))}")
|
|
471
|
+
print(f" kinds : {dict(collections.Counter(e['involvedObject'].get('kind') for e in events))}")
|
|
472
|
+
|
|
473
|
+
oom_hits = [e for e in events if re.search(r"oom|evict|memorypressure", json.dumps(e), re.I)]
|
|
474
|
+
print(f"\n OOM / eviction / MemoryPressure events: {len(oom_hits)}")
|
|
475
|
+
for event in oom_hits[:10]:
|
|
476
|
+
print(f" {stamp(event)} {event.get('reason')} "
|
|
477
|
+
f"{event['involvedObject'].get('kind')}/{event['involvedObject'].get('name')}: "
|
|
478
|
+
f"{(event.get('message') or '')[:140]}")
|
|
479
|
+
|
|
480
|
+
warnings = collections.Counter()
|
|
481
|
+
for event in events:
|
|
482
|
+
if event.get("type") == "Warning":
|
|
483
|
+
msg = re.sub(r'"[^"]+"', '"<name>"', (event.get("message") or ""))[:130]
|
|
484
|
+
warnings[(event.get("reason"), msg)] += event.get("count") or 1
|
|
485
|
+
print(f"\n Warning events (deduped, top 15):")
|
|
486
|
+
for (reason, msg), count in warnings.most_common(15):
|
|
487
|
+
print(f" [{count:>4}x] {reason}: {msg}")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# --------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
def main() -> int:
|
|
493
|
+
parser = argparse.ArgumentParser(description=__doc__,
|
|
494
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
495
|
+
parser.add_argument("bundle", type=Path, help="path to the extracted support bundle directory")
|
|
496
|
+
parser.add_argument("-n", "--namespace", default="openhands",
|
|
497
|
+
help="namespace to focus on (default: openhands)")
|
|
498
|
+
parser.add_argument("--expand-runtimes", action="store_true",
|
|
499
|
+
help=f"list every {RUNTIME_PREFIX}* sandbox pod instead of summarising")
|
|
500
|
+
parser.add_argument("--section", action="append", choices=SECTIONS,
|
|
501
|
+
help="only run these sections (repeatable; default: all)")
|
|
502
|
+
args = parser.parse_args()
|
|
503
|
+
|
|
504
|
+
root: Path = args.bundle.expanduser().resolve()
|
|
505
|
+
if not (root / "cluster-resources").is_dir():
|
|
506
|
+
print(f"error: {root} does not look like a support bundle "
|
|
507
|
+
f"(no cluster-resources/ directory)", file=sys.stderr)
|
|
508
|
+
return 1
|
|
509
|
+
|
|
510
|
+
wanted = args.section or list(SECTIONS)
|
|
511
|
+
now = capture_time(root)
|
|
512
|
+
|
|
513
|
+
if "meta" in wanted:
|
|
514
|
+
section_meta(root, now)
|
|
515
|
+
if "analyzers" in wanted:
|
|
516
|
+
section_analyzers(root)
|
|
517
|
+
if "pods" in wanted:
|
|
518
|
+
section_pods(root, args.namespace, now, args.expand_runtimes)
|
|
519
|
+
if "restarts" in wanted:
|
|
520
|
+
section_restarts(root, now)
|
|
521
|
+
if "top" in wanted:
|
|
522
|
+
section_top(root, args.namespace)
|
|
523
|
+
if "alloc" in wanted:
|
|
524
|
+
section_alloc(root)
|
|
525
|
+
if "events" in wanted:
|
|
526
|
+
section_events(root, args.namespace, now)
|
|
527
|
+
print()
|
|
528
|
+
return 0
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
if __name__ == "__main__":
|
|
532
|
+
sys.exit(main())
|
|
@@ -218,6 +218,8 @@ Source: [`examples/`](https://github.com/OpenHands/software-agent-sdk/tree/main/
|
|
|
218
218
|
- [`14_client_defined_tools.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/14_client_defined_tools.py)
|
|
219
219
|
- [`15_openai_compatible_gateway.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/15_openai_compatible_gateway.py)
|
|
220
220
|
- [`16_deferred_init.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/16_deferred_init.py)
|
|
221
|
+
- [`17_convo_with_agent_sandbox_server.py`](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/02_remote_agent_server/17_convo_with_agent_sandbox_server.py)
|
|
222
|
+
- [`agent_sandbox_deploy`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server/agent_sandbox_deploy)
|
|
221
223
|
- [`hook_scripts`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server/hook_scripts)
|
|
222
224
|
- [`scripts`](https://github.com/OpenHands/software-agent-sdk/tree/main/examples/02_remote_agent_server/scripts)
|
|
223
225
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exercise the actual shipped entrypoints with mocked GitHub/SDK boundaries."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def worker(name, tmp_path, monkeypatch):
|
|
12
|
+
manifest = json.loads(
|
|
13
|
+
(ROOT / f"automations/catalog/{name}/manifest.json").read_text()
|
|
14
|
+
)
|
|
15
|
+
bundle = tmp_path / name
|
|
16
|
+
bundle.mkdir()
|
|
17
|
+
for filename, source in manifest["setup"]["bundle"]["files"].items():
|
|
18
|
+
(bundle / filename).write_text((ROOT / source).read_text())
|
|
19
|
+
for module in ("main", "github_client", "qa_prompt", "worker"):
|
|
20
|
+
monkeypatch.delitem(sys.modules, module, raising=False)
|
|
21
|
+
monkeypatch.syspath_prepend(str(bundle))
|
|
22
|
+
spec = importlib.util.spec_from_file_location("worker", bundle / "worker.py")
|
|
23
|
+
module = importlib.util.module_from_spec(spec)
|
|
24
|
+
spec.loader.exec_module(module)
|
|
25
|
+
return module
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Contracts for the shared GitHub automation foundation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from urllib.error import HTTPError
|
|
5
|
+
|
|
6
|
+
import github_client
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_repository_runs_are_isolated(tmp_path, monkeypatch):
|
|
11
|
+
monkeypatch.chdir(tmp_path)
|
|
12
|
+
monkeypatch.setattr("sys.argv", ["worker.py"])
|
|
13
|
+
(tmp_path / "config.json").write_text(
|
|
14
|
+
json.dumps(
|
|
15
|
+
{
|
|
16
|
+
"repos": ["owner/failed", "owner/next"],
|
|
17
|
+
"github_token_secret": "GITHUB_TOKEN",
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
visited = []
|
|
22
|
+
|
|
23
|
+
class Automation:
|
|
24
|
+
def __init__(self, *, repository, github_token_secret, conversation):
|
|
25
|
+
assert github_token_secret == "GITHUB_TOKEN"
|
|
26
|
+
self.repository = repository
|
|
27
|
+
|
|
28
|
+
def run(self):
|
|
29
|
+
visited.append(self.repository)
|
|
30
|
+
if self.repository == "owner/failed":
|
|
31
|
+
raise TimeoutError
|
|
32
|
+
|
|
33
|
+
with pytest.raises(RuntimeError, match="owner/failed"):
|
|
34
|
+
github_client.run_repositories(Automation)
|
|
35
|
+
|
|
36
|
+
assert visited == ["owner/failed", "owner/next"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_pagination_rejects_a_non_list_response(monkeypatch):
|
|
40
|
+
monkeypatch.setattr(
|
|
41
|
+
github_client, "github_request", lambda *args, **kwargs: ({}, {})
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
with pytest.raises(TypeError, match="paginated GitHub list"):
|
|
45
|
+
github_client.github_paginate("token", "/repos/owner/repo/issues")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
"state,reason,expected",
|
|
50
|
+
[
|
|
51
|
+
("closed", "completed", True),
|
|
52
|
+
("closed", "not_planned", False),
|
|
53
|
+
("open", None, False),
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
def test_dependencies_must_be_completed(state, reason, expected):
|
|
57
|
+
repository = object.__new__(github_client.GitHubRepository)
|
|
58
|
+
repository._completed_dependencies = {}
|
|
59
|
+
repository.gh = lambda *args: {"state": state, "state_reason": reason}
|
|
60
|
+
|
|
61
|
+
assert repository.dependencies_complete({"body": "Depends on: #12"}) is expected
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_dependency_permission_errors_fail_closed():
|
|
65
|
+
repository = object.__new__(github_client.GitHubRepository)
|
|
66
|
+
repository._completed_dependencies = {}
|
|
67
|
+
|
|
68
|
+
def denied(*args):
|
|
69
|
+
raise HTTPError("https://api.github.com", 403, "Forbidden", {}, None)
|
|
70
|
+
|
|
71
|
+
repository.gh = denied
|
|
72
|
+
with pytest.raises(HTTPError):
|
|
73
|
+
repository.dependencies_complete({"body": "Depends on: #12"})
|