@openhands/extensions 0.25.0 → 0.27.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/.claude-plugin/marketplace.json +24 -0
- package/.codex-plugin/marketplace.json +24 -0
- package/.github/pull_request_template.md +17 -2
- package/.github/scripts/check_pr_description.py +64 -5
- package/.github/scripts/markdown_sections.py +95 -0
- package/.github/workflows/build-vscode-app.yml +30 -0
- package/.github/workflows/check-extensions.yml +3 -0
- package/.github/workflows/tests.yml +26 -0
- package/.plugin/marketplace.json +24 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +3 -2
- package/README.md +9 -3
- package/apps/README.md +20 -0
- package/apps/catalog.json +10 -0
- package/apps/index.d.ts +11 -0
- package/apps/index.js +5 -0
- package/apps/vscode/README.md +13 -0
- package/apps/vscode/canvas-extension.json +19 -0
- package/apps/vscode/extension.js +114 -0
- package/apps/vscode/scripts/package.mjs +84 -0
- package/automations/README.md +7 -6
- package/automations/bundle-index.js +15 -15
- package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
- package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
- package/automations/catalog/news-digest/manifest.json +2 -2
- package/automations/index.d.ts +9 -1
- package/automations/interface.json +9 -0
- package/automations/interface.schema.json +37 -2
- package/index.d.ts +2 -0
- package/index.js +1 -0
- package/marketplaces/openhands-extensions.json +24 -0
- package/package.json +9 -1
- package/plugins/openhands/skills/openhands-api/README.md +1 -1
- package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
- package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
- package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
- package/plugins/runtime-api-configs/README.md +59 -0
- package/plugins/runtime-api-configs/SKILL.md +240 -0
- package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
- package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
- package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/scripts/check_plugin_resources.py +116 -0
- package/scripts/sync_extensions.py +32 -5
- package/skills/canvas-extension-api/SKILL.md +34 -3
- package/skills/canvas-extension-api/references/backend-safety.md +14 -0
- package/skills/canvas-extension-api/references/connections.md +24 -0
- package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
- package/skills/canvas-extension-api/references/v1-contract.md +53 -3
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
- package/skills/github/scripts/agent_conversation.py +223 -43
- package/skills/github/scripts/github_client.py +67 -15
- package/skills/github-agents-md-maintainer/README.md +7 -0
- package/skills/github-agents-md-maintainer/SKILL.md +13 -6
- package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
- package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
- package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
- package/skills/github-issue-to-pr/scripts/worker.py +10 -1
- package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
- package/skills/github-issue-triage/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/README.md +5 -4
- package/skills/github-pr-reviewer/SKILL.md +8 -17
- package/skills/github-pr-reviewer/references/state-schema.md +0 -15
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
- package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
- package/skills/github-pr-reviewer/scripts/worker.py +131 -108
- package/skills/github-repo-monitor/scripts/github_client.py +67 -15
- package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
- package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
- package/skills/gitlab-issue-to-mr/README.md +12 -0
- package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
- package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
- package/skills/incident-retrospective/SKILL.md +5 -5
- package/skills/index.js +23 -14
- package/skills/jira-issue-to-pr/README.md +12 -4
- package/skills/jira-issue-to-pr/SKILL.md +129 -18
- package/skills/jira-issue-to-pr/references/setup.md +8 -0
- package/skills/jira-issue-to-pr/scripts/main.py +181 -54
- package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
- package/skills/linear-triage/SKILL.md +5 -5
- package/skills/news-digest/README.md +6 -0
- package/skills/news-digest/SKILL.md +19 -9
- package/skills/news-digest/commands/news-digest-setup.md +1 -1
- package/skills/news-digest/scripts/main.py +133 -15
- package/skills/openhands-api/README.md +1 -1
- package/skills/openhands-api/SKILL.md +3 -3
- package/skills/openhands-api/scripts/openhands_api.py +13 -0
- package/skills/openhands-automation/SKILL.md +66 -4
- package/skills/openhands-automation/references/custom-automation.md +21 -7
- package/skills/research-brief/SKILL.md +5 -5
- package/skills/slack-standup-digest/SKILL.md +5 -5
- package/skills/ticket-to-code-change/SKILL.md +6 -3
- package/skills/use-jev/.claude-plugin/plugin.json +8 -0
- package/skills/use-jev/.codex-plugin/plugin.json +8 -0
- package/skills/use-jev/.plugin/plugin.json +8 -0
- package/skills/use-jev/README.md +66 -0
- package/skills/use-jev/SKILL.md +66 -0
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +212 -0
- package/tests/test_apps_catalog.py +26 -0
- package/tests/test_automation_setup.py +44 -0
- package/tests/test_check_pr_description.py +89 -6
- package/tests/test_github_reviewer_delivery.py +254 -118
- package/tests/test_github_stale_ci_pr_closer.py +56 -0
- package/tests/test_interface_manifest.py +23 -1
- package/tests/test_jira_issue_to_pr.py +312 -0
- package/tests/test_jira_setup_webhook.py +309 -0
- package/tests/test_openhands_api_python.py +37 -0
- package/tests/test_skill_plugin_loading.py +25 -15
- package/tests/test_sync_extensions.py +102 -0
- package/tests/test_vscode_app.py +33 -0
- package/tests/vscode-app.test.mjs +43 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# runtime-api-configs
|
|
2
|
+
|
|
3
|
+
Python CLI for managing warm sandbox pools on OpenHands Enterprise (OHE) via
|
|
4
|
+
the `runtime-api` admin endpoints. This plugin supersedes the ad-hoc
|
|
5
|
+
`warm-runtime-configs.sh` snippets embedded in the docs.
|
|
6
|
+
|
|
7
|
+
- **Skill definition:** [`SKILL.md`](./SKILL.md)
|
|
8
|
+
- **CLI:** [`scripts/warm_runtime_configs.py`](./scripts/warm_runtime_configs.py)
|
|
9
|
+
- **Docs:** <https://docs.openhands.dev/enterprise/custom-sandbox-images/multiple-images-warm-pools>
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
Export the runtime-api URL and the admin password you set in the OHE Admin
|
|
14
|
+
Console (VM installs) or that lives in the `admin-password` Kubernetes
|
|
15
|
+
secret (Helm installs):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
export RUNTIME_API_URL=https://runtime-api.<your-base-domain>
|
|
19
|
+
export ADMIN_PASSWORD=<the-runtime-api-admin-password>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then drive the CLI over HTTPS. No `kubectl`, no SSH:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# 1. Inspect the effective set
|
|
26
|
+
python3 scripts/warm_runtime_configs.py list
|
|
27
|
+
|
|
28
|
+
# 2. Register a new custom sandbox image
|
|
29
|
+
python3 scripts/warm_runtime_configs.py template v1_current \
|
|
30
|
+
--image ghcr.io/your-org/openhands-php:8.4-v1 --count 1 \
|
|
31
|
+
| python3 scripts/warm_runtime_configs.py save php-web --file -
|
|
32
|
+
|
|
33
|
+
# 3. Delete when finished
|
|
34
|
+
python3 scripts/warm_runtime_configs.py delete php-web
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
If you would rather pull all three env vars from Kubernetes secrets in one
|
|
38
|
+
step, see the `bootstrap` subcommand in [`SKILL.md`](./SKILL.md) - on
|
|
39
|
+
Replicated VM installs it needs `sudo -E`; on Helm it needs
|
|
40
|
+
`kubectl port-forward` and `--skip-url`.
|
|
41
|
+
|
|
42
|
+
## Why a plugin instead of a shell script
|
|
43
|
+
|
|
44
|
+
- **Runs anywhere Python 3.9+ is available.**
|
|
45
|
+
- **No third-party dependencies** - pure stdlib (`urllib`, `hashlib`,
|
|
46
|
+
`argparse`).
|
|
47
|
+
- **First-class `template` subcommand** replaces `jq` gymnastics when
|
|
48
|
+
deriving custom configurations from the installer default.
|
|
49
|
+
- **Testable**: `tests/test_warm_runtime_configs.py` exercises the full
|
|
50
|
+
request/response path against an in-process HTTP server.
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python3 -m unittest discover -s tests
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The tests spin up a stdlib `http.server` on an ephemeral port, so they need
|
|
59
|
+
no cluster access.
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: runtime-api-configs
|
|
3
|
+
description: >-
|
|
4
|
+
Manage warm sandbox pools on OpenHands Enterprise by driving the runtime-api
|
|
5
|
+
admin endpoints (list, save, delete warm runtime configurations) from a
|
|
6
|
+
Python CLI. Use when an OpenHands Enterprise administrator asks to add,
|
|
7
|
+
update, delete, or inspect custom sandbox images, warm pools, or
|
|
8
|
+
`warm-runtime-configs`, or when the docs point to
|
|
9
|
+
`enterprise/custom-sandbox-images/multiple-images-warm-pools`.
|
|
10
|
+
triggers:
|
|
11
|
+
- warm-runtime-config
|
|
12
|
+
- warm runtime config
|
|
13
|
+
- warm pool
|
|
14
|
+
- warm-runtime-configs
|
|
15
|
+
- custom sandbox image
|
|
16
|
+
- runtime-api admin
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Runtime API - Warm Runtime Configurations
|
|
20
|
+
|
|
21
|
+
This plugin manages the warm sandbox pools used by OpenHands Enterprise. It
|
|
22
|
+
replaces the ad-hoc `warm-runtime-configs.sh` shell script embedded in the
|
|
23
|
+
docs and gives administrators a single, testable Python CLI for the same
|
|
24
|
+
workflow.
|
|
25
|
+
|
|
26
|
+
The CLI talks HTTP to the runtime-api service. It never modifies the cluster
|
|
27
|
+
directly, and it uses only the Python standard library, so it runs anywhere
|
|
28
|
+
`python3` is available.
|
|
29
|
+
|
|
30
|
+
## When to use this skill
|
|
31
|
+
|
|
32
|
+
Reach for this skill whenever the user wants to:
|
|
33
|
+
|
|
34
|
+
- **List** the effective warm-runtime configurations on an OHE install.
|
|
35
|
+
- **Add or update** a custom sandbox image (e.g. `openhands-php:8.4-v1`) so
|
|
36
|
+
users can select it from the **Settings → Application → Default Sandbox**
|
|
37
|
+
dropdown.
|
|
38
|
+
- **Delete** a database-managed configuration, restoring an installer-managed
|
|
39
|
+
entry with the same name.
|
|
40
|
+
- **Refresh configurations after upgrading OHE**, when API-managed frozen
|
|
41
|
+
snapshots need to be re-derived from the new installer template.
|
|
42
|
+
- **Bootstrap credentials** (`RUNTIME_API_URL`, `API_KEY`, `ADMIN_PASSWORD`)
|
|
43
|
+
from Kubernetes secrets so subsequent runs need no cluster access.
|
|
44
|
+
|
|
45
|
+
If the user is instead asking how to **build** a custom sandbox image
|
|
46
|
+
(Dockerfile, versioning), point them at
|
|
47
|
+
[Building a Custom Image](https://docs.openhands.dev/enterprise/custom-sandbox-images/building-custom-images)
|
|
48
|
+
- image building is out of scope for this skill.
|
|
49
|
+
|
|
50
|
+
## Prerequisites
|
|
51
|
+
|
|
52
|
+
- OpenHands Enterprise **0.64.0 or later**.
|
|
53
|
+
- Python **3.9+** on the machine running the CLI. No third-party packages.
|
|
54
|
+
- The **Runtime API Admin Password**:
|
|
55
|
+
- **VM (Replicated) installs**: set via *Admin Console → Config → Sandbox
|
|
56
|
+
Configuration → Runtime API Admin Password*.
|
|
57
|
+
- **Helm installs**: the value stored in the `admin-password` Kubernetes
|
|
58
|
+
secret in the runtime-api namespace. Helm has no Admin Console.
|
|
59
|
+
- **Helm installs only**: the runtime-api chart must be running in **overlay
|
|
60
|
+
mode** and have warm pools enabled, so `v1_current` shows up in `list` and
|
|
61
|
+
can be used as a `template` source. Set both in your umbrella values:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
runtime-api:
|
|
65
|
+
warmRuntimes:
|
|
66
|
+
enabled: true
|
|
67
|
+
env:
|
|
68
|
+
WARM_RUNTIME_CONFIG_OVERLAY: "1"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Overlay mode is on by default on Replicated VM installs. Without it on
|
|
72
|
+
Helm, `list` returns `[]` on a fresh install and `template v1_current`
|
|
73
|
+
fails.
|
|
74
|
+
|
|
75
|
+
## Setup
|
|
76
|
+
|
|
77
|
+
On any machine with HTTPS reachability to the runtime-api, export two env
|
|
78
|
+
vars:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
export RUNTIME_API_URL=https://runtime-api.<your-base-domain>
|
|
82
|
+
export ADMIN_PASSWORD=<runtime-api-admin-password> # see Prerequisites above
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
That is the full setup on any install where the runtime-api ingress is
|
|
86
|
+
reachable from the machine running the CLI. No `kubectl`, no SSH, no
|
|
87
|
+
cluster access. If the runtime-api is cluster-internal (Helm default), see
|
|
88
|
+
the Helm entry in the Advanced section below to port-forward first.
|
|
89
|
+
|
|
90
|
+
Under the hood, the CLI uses the admin password directly for `save` and
|
|
91
|
+
`delete` (via the PBKDF2 handshake), and for `list` and `template` it logs
|
|
92
|
+
in as admin and fetches the `default` read-only API key over HTTPS from
|
|
93
|
+
`/api/admin/api-keys`.
|
|
94
|
+
|
|
95
|
+
If you already know the read-only API key (for example, from an operator
|
|
96
|
+
runbook), export it too and the CLI will skip the extra admin-login round
|
|
97
|
+
trip on reads:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
export API_KEY=<default api key>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
If you have `kubectl` access to the cluster and prefer to pull all three
|
|
104
|
+
env vars from Kubernetes secrets in one step, see
|
|
105
|
+
**[Advanced: bootstrap from Kubernetes](#advanced-bootstrap-from-kubernetes)**
|
|
106
|
+
at the bottom of this file.
|
|
107
|
+
|
|
108
|
+
## CLI reference
|
|
109
|
+
|
|
110
|
+
Run with `python3 scripts/warm_runtime_configs.py <subcommand>`. Each
|
|
111
|
+
subcommand exits non-zero on error and prints the runtime-api's HTTP body
|
|
112
|
+
on failure.
|
|
113
|
+
|
|
114
|
+
| Subcommand | Auth | Purpose |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `list` | `API_KEY` if set, else admin login | Print the effective set of configurations. |
|
|
117
|
+
| `template <source>` | `API_KEY` if set, else admin login | Fetch a config, strip identity fields, override image/count, print JSON ready to `save`. |
|
|
118
|
+
| `save <name> [--file F]` | Admin password | Upsert a configuration. Reads JSON from `--file` or stdin. |
|
|
119
|
+
| `delete <name>` | Admin password | Delete a database-managed configuration. |
|
|
120
|
+
| `bootstrap` | kubectl | Print `export` lines for `RUNTIME_API_URL`, `API_KEY`, `ADMIN_PASSWORD`. Optional; see [Advanced](#advanced-bootstrap-from-kubernetes) below. |
|
|
121
|
+
|
|
122
|
+
Full help: `python3 scripts/warm_runtime_configs.py --help` and
|
|
123
|
+
`python3 scripts/warm_runtime_configs.py <subcommand> --help`.
|
|
124
|
+
|
|
125
|
+
## Typical workflow
|
|
126
|
+
|
|
127
|
+
Register a new PHP sandbox image once credentials are in the environment:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# 1. Derive a config JSON from the current default, changing only the image
|
|
131
|
+
python3 scripts/warm_runtime_configs.py template v1_current \
|
|
132
|
+
--image ghcr.io/your-org/openhands-php:8.4-v1 --count 1 \
|
|
133
|
+
--output php-web.json
|
|
134
|
+
|
|
135
|
+
# 2. Save it
|
|
136
|
+
python3 scripts/warm_runtime_configs.py save php-web --file php-web.json
|
|
137
|
+
|
|
138
|
+
# 3. Confirm
|
|
139
|
+
python3 scripts/warm_runtime_configs.py list
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Within about a minute the pool is ready and `php-web` appears in
|
|
143
|
+
**Settings → Application → Default Sandbox**.
|
|
144
|
+
|
|
145
|
+
Deleting an API-managed configuration removes it from the effective set:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
python3 scripts/warm_runtime_configs.py delete php-web
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`delete` operates on database rows only, so it 404s for names that have
|
|
152
|
+
never been saved through this CLI (installer-managed entries like
|
|
153
|
+
`v1_current` on a fresh install fall in that bucket). In overlay mode, if
|
|
154
|
+
you had previously saved a database row that shadowed a same-named
|
|
155
|
+
ConfigMap entry, deleting the row reverts to the ConfigMap entry on the
|
|
156
|
+
next reconciler cycle.
|
|
157
|
+
|
|
158
|
+
## After an OHE upgrade
|
|
159
|
+
|
|
160
|
+
Frozen API-managed configurations do not follow release bumps. After every
|
|
161
|
+
OHE upgrade, re-derive each API-managed configuration from the refreshed
|
|
162
|
+
default template, keeping each configuration's current image tag and pool
|
|
163
|
+
size unless you deliberately change them:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
# One "name image count" tuple per API-managed config; edit for your fleet.
|
|
167
|
+
# Preserving the existing pool count matters: hardcoding it would silently
|
|
168
|
+
# shrink pools on every refresh.
|
|
169
|
+
while read -r name image count; do
|
|
170
|
+
[ -z "$name" ] && continue
|
|
171
|
+
python3 scripts/warm_runtime_configs.py template v1_current \
|
|
172
|
+
--image "$image" --count "$count" \
|
|
173
|
+
| python3 scripts/warm_runtime_configs.py save "$name" --file -
|
|
174
|
+
done <<'EOF'
|
|
175
|
+
php-web ghcr.io/your-org/openhands-php:8.4-v2 3
|
|
176
|
+
ruby-app ghcr.io/your-org/openhands-ruby:3.3-v2 2
|
|
177
|
+
node-monorepo ghcr.io/your-org/openhands-node:20-v2 1
|
|
178
|
+
EOF
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Two caveats worth spelling out:
|
|
182
|
+
|
|
183
|
+
- `template` refreshes the *command* and *environment* to match the new
|
|
184
|
+
`v1_current`, but it keeps whatever image tag you pass. To actually
|
|
185
|
+
pick up a new agent-server version, the image itself must be rebuilt
|
|
186
|
+
against the matching agent-server base. This loop alone does not do
|
|
187
|
+
that.
|
|
188
|
+
- Skipping this step leaves configurations pointing at the previous
|
|
189
|
+
agent-server command/environment, and new conversations fail with a
|
|
190
|
+
version mismatch until the configurations are updated.
|
|
191
|
+
|
|
192
|
+
## Environment variables
|
|
193
|
+
|
|
194
|
+
| Variable | Used by | Notes |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `RUNTIME_API_URL` | all HTTP subcommands | e.g. `https://runtime-api.example.com` or `http://localhost:5000`. |
|
|
197
|
+
| `ADMIN_PASSWORD` | `save`, `delete`; also `list`/`template` when `API_KEY` is unset | Runs the PBKDF2 challenge-response handshake. |
|
|
198
|
+
| `ADMIN_TOKEN` | Anywhere `ADMIN_PASSWORD` is used | Pre-obtained admin JWT. When set, it wins over `ADMIN_PASSWORD` and skips the handshake. |
|
|
199
|
+
| `API_KEY` | `list`, `template` (optional) | Sent as `X-API-Key`. Optional: when unset, the CLI fetches the `default` key over HTTPS via admin login. |
|
|
200
|
+
| `NAMESPACE` | `bootstrap` | Defaults to `openhands`. Overridable via `--namespace`. |
|
|
201
|
+
|
|
202
|
+
## Advanced: bootstrap from Kubernetes
|
|
203
|
+
|
|
204
|
+
`bootstrap` extracts all three env vars from Kubernetes secrets in one
|
|
205
|
+
step. Handy for cluster operators and CI, but the default HTTPS-only
|
|
206
|
+
workflow above is preferred for interactive admin use.
|
|
207
|
+
|
|
208
|
+
### VM (Replicated) install
|
|
209
|
+
|
|
210
|
+
The k0s kubeconfig is root-owned, so the invocation runs under sudo:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
eval "$(sudo -E python3 scripts/warm_runtime_configs.py bootstrap --namespace openhands)"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Helm install
|
|
217
|
+
|
|
218
|
+
The runtime-api is cluster-internal on Helm (chart default is
|
|
219
|
+
`ingress.enabled: false`). The Kubernetes service name is release-prefixed
|
|
220
|
+
via `include "runtime-api.fullname"`, so it is not literally `runtime-api`
|
|
221
|
+
- discover it and port-forward, then bootstrap without touching
|
|
222
|
+
`RUNTIME_API_URL`:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
SVC=$(kubectl -n openhands get svc \
|
|
226
|
+
-l app.kubernetes.io/name=runtime-api \
|
|
227
|
+
-o jsonpath='{.items[0].metadata.name}')
|
|
228
|
+
kubectl -n openhands port-forward "svc/$SVC" 5000:5000 &
|
|
229
|
+
export RUNTIME_API_URL=http://localhost:5000
|
|
230
|
+
eval "$(python3 scripts/warm_runtime_configs.py bootstrap --namespace openhands --skip-url)"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`--skip-url` leaves `RUNTIME_API_URL` alone. If the operator set
|
|
234
|
+
`nameOverride` on the runtime-api subchart, adjust the label selector to
|
|
235
|
+
match.
|
|
236
|
+
|
|
237
|
+
## See also
|
|
238
|
+
|
|
239
|
+
- Docs: [Configuring Custom Sandbox Images](https://docs.openhands.dev/enterprise/custom-sandbox-images/multiple-images-warm-pools)
|
|
240
|
+
- Docs: [Using Custom Images](https://docs.openhands.dev/enterprise/custom-sandbox-images/using-custom-images)
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Manage warm sandbox configurations on the OpenHands Enterprise runtime-api.
|
|
3
|
+
|
|
4
|
+
Standard-library-only CLI. Runs on any host with Python 3.9+, including
|
|
5
|
+
inside the runtime-api pod on Helm installs.
|
|
6
|
+
|
|
7
|
+
Environment variables:
|
|
8
|
+
RUNTIME_API_URL Base URL, e.g. https://runtime-api.example.com
|
|
9
|
+
ADMIN_PASSWORD Password used for the PBKDF2 challenge/response handshake
|
|
10
|
+
API_KEY Sent as X-API-Key on read operations. Optional: when
|
|
11
|
+
unset, list/template log in as admin and fetch the
|
|
12
|
+
'default' API key over HTTPS via /api/admin/api-keys
|
|
13
|
+
ADMIN_TOKEN Pre-obtained admin JWT; when set, skips the handshake
|
|
14
|
+
NAMESPACE Kubernetes namespace, default 'openhands' (bootstrap only)
|
|
15
|
+
|
|
16
|
+
Exit codes:
|
|
17
|
+
0 success
|
|
18
|
+
1 precondition failure (missing env vars, no matching config, etc.)
|
|
19
|
+
2 HTTP or connection error against the runtime-api, or argparse usage
|
|
20
|
+
error (argparse's own default)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import base64
|
|
27
|
+
import binascii
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import shlex
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import urllib.error
|
|
36
|
+
import urllib.parse
|
|
37
|
+
import urllib.request
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------- HTTP helpers ---------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _runtime_api_url() -> str:
|
|
45
|
+
url = os.environ.get("RUNTIME_API_URL", "").rstrip("/")
|
|
46
|
+
if not url:
|
|
47
|
+
_fail(
|
|
48
|
+
"RUNTIME_API_URL is not set. Export it (e.g. "
|
|
49
|
+
"https://runtime-api.<your-base-domain>) or run `bootstrap`."
|
|
50
|
+
)
|
|
51
|
+
return url
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _request(
|
|
55
|
+
path: str,
|
|
56
|
+
method: str = "GET",
|
|
57
|
+
body: Any = None,
|
|
58
|
+
headers: dict[str, str] | None = None,
|
|
59
|
+
) -> Any:
|
|
60
|
+
api_url = _runtime_api_url()
|
|
61
|
+
req_headers = {"Content-Type": "application/json", **(headers or {})}
|
|
62
|
+
req = urllib.request.Request(
|
|
63
|
+
f"{api_url}{path}", method=method, headers=req_headers
|
|
64
|
+
)
|
|
65
|
+
if body is not None:
|
|
66
|
+
req.data = json.dumps(body).encode()
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(req) as resp:
|
|
69
|
+
payload = resp.read().decode()
|
|
70
|
+
return json.loads(payload) if payload else {}
|
|
71
|
+
except urllib.error.HTTPError as exc:
|
|
72
|
+
_fail(f"HTTP {exc.code} {method} {path}: {exc.read().decode()}", code=2)
|
|
73
|
+
except urllib.error.URLError as exc:
|
|
74
|
+
_fail(f"Could not connect to {api_url}: {exc.reason}", code=2)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------- Auth -----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _api_key() -> str:
|
|
81
|
+
key = os.environ.get("API_KEY")
|
|
82
|
+
if key:
|
|
83
|
+
return key
|
|
84
|
+
# No API_KEY exported. If admin creds are available, log in and pull the
|
|
85
|
+
# 'default' key value over HTTPS - the same value bootstrap would extract
|
|
86
|
+
# from the k8s secret. Lets an admin drive the CLI end-to-end with just
|
|
87
|
+
# RUNTIME_API_URL + ADMIN_PASSWORD, no cluster access.
|
|
88
|
+
if not (os.environ.get("ADMIN_TOKEN") or os.environ.get("ADMIN_PASSWORD")):
|
|
89
|
+
_fail(
|
|
90
|
+
"API_KEY is not set, and no ADMIN_PASSWORD (or ADMIN_TOKEN) to "
|
|
91
|
+
"fall back to. Either export API_KEY, or export ADMIN_PASSWORD "
|
|
92
|
+
"so the CLI can fetch the default API key over HTTPS."
|
|
93
|
+
)
|
|
94
|
+
token = _admin_token()
|
|
95
|
+
keys = _request(
|
|
96
|
+
"/api/admin/api-keys",
|
|
97
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
98
|
+
)
|
|
99
|
+
for k in keys or []:
|
|
100
|
+
if k.get("name") == "default":
|
|
101
|
+
return k["key_value"]
|
|
102
|
+
_fail("No API key named 'default' returned by /api/admin/api-keys.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _admin_token() -> str:
|
|
106
|
+
"""Return a valid admin JWT, using ADMIN_TOKEN if set or logging in."""
|
|
107
|
+
token = os.environ.get("ADMIN_TOKEN")
|
|
108
|
+
if token:
|
|
109
|
+
return token
|
|
110
|
+
password = os.environ.get("ADMIN_PASSWORD")
|
|
111
|
+
if not password:
|
|
112
|
+
_fail("ADMIN_PASSWORD (or ADMIN_TOKEN) is not set.")
|
|
113
|
+
|
|
114
|
+
challenge = _request("/api/admin/challenge")
|
|
115
|
+
derived = hashlib.pbkdf2_hmac(
|
|
116
|
+
"sha256",
|
|
117
|
+
password.encode(),
|
|
118
|
+
(challenge["salt"] + challenge["challenge"]).encode(),
|
|
119
|
+
challenge["iterations"],
|
|
120
|
+
dklen=32,
|
|
121
|
+
)
|
|
122
|
+
resp = _request(
|
|
123
|
+
"/api/admin/login",
|
|
124
|
+
method="POST",
|
|
125
|
+
body={
|
|
126
|
+
"challenge": challenge["challenge"],
|
|
127
|
+
"hash": binascii.hexlify(derived).decode(),
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
return resp["token"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------- Subcommand implementations -------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
137
|
+
resp = _request(
|
|
138
|
+
"/api/warm-runtime-configs", headers={"X-API-Key": _api_key()}
|
|
139
|
+
)
|
|
140
|
+
print(json.dumps(resp["configs"], indent=2))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_template(args: argparse.Namespace) -> None:
|
|
144
|
+
resp = _request(
|
|
145
|
+
"/api/warm-runtime-configs", headers={"X-API-Key": _api_key()}
|
|
146
|
+
)
|
|
147
|
+
match = next(
|
|
148
|
+
(c for c in resp["configs"] if c.get("name") == args.source), None
|
|
149
|
+
)
|
|
150
|
+
if match is None:
|
|
151
|
+
_fail(f"No configuration named {args.source!r} in the effective set.")
|
|
152
|
+
|
|
153
|
+
# Strip identity fields that must not appear in a save body.
|
|
154
|
+
for field in ("name", "source"):
|
|
155
|
+
match.pop(field, None)
|
|
156
|
+
|
|
157
|
+
if args.image is not None:
|
|
158
|
+
match["image"] = args.image
|
|
159
|
+
if args.count is not None:
|
|
160
|
+
match["count"] = args.count
|
|
161
|
+
|
|
162
|
+
payload = json.dumps(match, indent=2)
|
|
163
|
+
if args.output and args.output != "-":
|
|
164
|
+
with open(args.output, "w") as fh:
|
|
165
|
+
fh.write(payload + "\n")
|
|
166
|
+
else:
|
|
167
|
+
print(payload)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_save(args: argparse.Namespace) -> None:
|
|
171
|
+
if args.file in (None, "-"):
|
|
172
|
+
raw = sys.stdin.read()
|
|
173
|
+
if not raw.strip():
|
|
174
|
+
_fail("No JSON body on stdin. Pass --file, or pipe JSON in.")
|
|
175
|
+
else:
|
|
176
|
+
with open(args.file) as fh:
|
|
177
|
+
raw = fh.read()
|
|
178
|
+
try:
|
|
179
|
+
body = json.loads(raw)
|
|
180
|
+
except json.JSONDecodeError as exc:
|
|
181
|
+
_fail(f"Invalid JSON: {exc}")
|
|
182
|
+
|
|
183
|
+
name = urllib.parse.quote(args.name, safe="")
|
|
184
|
+
token = _admin_token()
|
|
185
|
+
saved = _request(
|
|
186
|
+
f"/api/admin/warm-runtime-configs/{name}",
|
|
187
|
+
method="PUT",
|
|
188
|
+
body=body,
|
|
189
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
190
|
+
)
|
|
191
|
+
print(
|
|
192
|
+
f"Saved {saved['name']} image={saved['image']} count={saved.get('count')}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_delete(args: argparse.Namespace) -> None:
|
|
197
|
+
name = urllib.parse.quote(args.name, safe="")
|
|
198
|
+
token = _admin_token()
|
|
199
|
+
resp = _request(
|
|
200
|
+
f"/api/admin/warm-runtime-configs/{name}",
|
|
201
|
+
method="DELETE",
|
|
202
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
203
|
+
)
|
|
204
|
+
print(resp.get("message", f"Deleted {args.name}"))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def cmd_bootstrap(args: argparse.Namespace) -> None:
|
|
208
|
+
if shutil.which("kubectl") is None:
|
|
209
|
+
_fail("kubectl not found on PATH. bootstrap needs cluster access.")
|
|
210
|
+
|
|
211
|
+
ns = args.namespace
|
|
212
|
+
lines: list[str] = []
|
|
213
|
+
|
|
214
|
+
if not args.skip_url:
|
|
215
|
+
host = _kubectl(
|
|
216
|
+
[
|
|
217
|
+
"get", "ingress", "-n", ns,
|
|
218
|
+
"-l", "app.kubernetes.io/name=runtime-api",
|
|
219
|
+
"-o", "jsonpath={.items[0].spec.rules[0].host}",
|
|
220
|
+
],
|
|
221
|
+
allow_empty=True,
|
|
222
|
+
)
|
|
223
|
+
if host:
|
|
224
|
+
lines.append(f"export RUNTIME_API_URL={shlex.quote(f'https://{host}')}")
|
|
225
|
+
else:
|
|
226
|
+
print(
|
|
227
|
+
"# No runtime-api ingress found; export RUNTIME_API_URL manually",
|
|
228
|
+
file=sys.stderr,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
api_key = _decode_secret(ns, "default-api-key", "default-api-key")
|
|
232
|
+
admin_password = _decode_secret(ns, "admin-password", "admin-password")
|
|
233
|
+
lines.append(f"export API_KEY={shlex.quote(api_key)}")
|
|
234
|
+
lines.append(f"export ADMIN_PASSWORD={shlex.quote(admin_password)}")
|
|
235
|
+
print("\n".join(lines))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------- kubectl helpers (bootstrap only) -------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _kubectl(argv: list[str], *, allow_empty: bool = False) -> str:
|
|
242
|
+
try:
|
|
243
|
+
out = subprocess.check_output(["kubectl", *argv], stderr=subprocess.PIPE)
|
|
244
|
+
except subprocess.CalledProcessError as exc:
|
|
245
|
+
_fail(
|
|
246
|
+
f"kubectl {' '.join(argv)} failed:\n{exc.stderr.decode()}",
|
|
247
|
+
code=2,
|
|
248
|
+
)
|
|
249
|
+
text = out.decode().strip()
|
|
250
|
+
if not text and not allow_empty:
|
|
251
|
+
_fail(f"kubectl {' '.join(argv)} returned no output.", code=2)
|
|
252
|
+
return text
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _decode_secret(namespace: str, secret: str, key: str) -> str:
|
|
256
|
+
encoded = _kubectl(
|
|
257
|
+
[
|
|
258
|
+
"get", "secret", secret, "-n", namespace,
|
|
259
|
+
"-o", f"jsonpath={{.data.{key}}}",
|
|
260
|
+
]
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
return base64.b64decode(encoded).decode()
|
|
264
|
+
except (binascii.Error, ValueError) as exc:
|
|
265
|
+
_fail(f"Could not decode secret {secret}/{key}: {exc}", code=2)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------- Utility --------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _fail(msg: str, *, code: int = 1) -> None:
|
|
272
|
+
print(msg, file=sys.stderr)
|
|
273
|
+
sys.exit(code)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
277
|
+
p = argparse.ArgumentParser(
|
|
278
|
+
prog="warm_runtime_configs",
|
|
279
|
+
description="Manage warm sandbox configurations on OpenHands Enterprise.",
|
|
280
|
+
)
|
|
281
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
282
|
+
|
|
283
|
+
sub.add_parser("list", help="List effective warm runtime configurations.").set_defaults(func=cmd_list)
|
|
284
|
+
|
|
285
|
+
t = sub.add_parser(
|
|
286
|
+
"template",
|
|
287
|
+
help="Fetch a config, strip name/source, override image/count, print JSON.",
|
|
288
|
+
)
|
|
289
|
+
t.add_argument("source", help="Name of an existing config to derive from (e.g. v1_current).")
|
|
290
|
+
t.add_argument("--image", help="Override the image field.")
|
|
291
|
+
t.add_argument("--count", type=int, help="Override the count field.")
|
|
292
|
+
t.add_argument("-o", "--output", help="Write to file instead of stdout ('-' = stdout).")
|
|
293
|
+
t.set_defaults(func=cmd_template)
|
|
294
|
+
|
|
295
|
+
s = sub.add_parser("save", help="Create or update a configuration (upsert).")
|
|
296
|
+
s.add_argument("name", help="Configuration name (URL identity).")
|
|
297
|
+
s.add_argument(
|
|
298
|
+
"--file", "-f",
|
|
299
|
+
help="Path to JSON body. Use '-' or omit to read from stdin.",
|
|
300
|
+
)
|
|
301
|
+
s.set_defaults(func=cmd_save)
|
|
302
|
+
|
|
303
|
+
d = sub.add_parser("delete", help="Delete a database-managed configuration.")
|
|
304
|
+
d.add_argument("name", help="Configuration name to delete.")
|
|
305
|
+
d.set_defaults(func=cmd_delete)
|
|
306
|
+
|
|
307
|
+
b = sub.add_parser(
|
|
308
|
+
"bootstrap",
|
|
309
|
+
help="Print `export` lines for RUNTIME_API_URL, API_KEY, ADMIN_PASSWORD.",
|
|
310
|
+
)
|
|
311
|
+
b.add_argument(
|
|
312
|
+
"--namespace", "-n",
|
|
313
|
+
default=os.environ.get("NAMESPACE", "openhands"),
|
|
314
|
+
help="Kubernetes namespace (default: %(default)s).",
|
|
315
|
+
)
|
|
316
|
+
b.add_argument(
|
|
317
|
+
"--skip-url", action="store_true",
|
|
318
|
+
help="Do not emit RUNTIME_API_URL (useful with `kubectl port-forward`).",
|
|
319
|
+
)
|
|
320
|
+
b.set_defaults(func=cmd_bootstrap)
|
|
321
|
+
|
|
322
|
+
return p
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def main(argv: list[str] | None = None) -> None:
|
|
326
|
+
args = build_parser().parse_args(argv)
|
|
327
|
+
args.func(args)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == "__main__":
|
|
331
|
+
main()
|