huaweicloud-devkit 1.1.10 → 1.1.11
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/plugins/huaweicloud-core/.claude-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.codex-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.cursor-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.hermes-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/.workbuddy-plugin/plugin.json +1 -1
- package/plugins/huaweicloud-core/openclaw.plugin.json +1 -1
- package/plugins/huaweicloud-core/skills/huawei-obs/SKILL.md +17 -16
- package/plugins/huaweicloud-core/skills/huawei-obs/references/common-pitfalls.md +168 -1
- package/plugins/huaweicloud-core/skills/huawei-sandbox/SKILL.md +75 -13
- package/plugins/huaweicloud-core/skills/huawei-sandbox/references/fullstack-postgrest.md +149 -0
- package/plugins/huaweicloud-core/skills/huawei-sandbox/references/tunnel-persistence.md +121 -0
- package/plugins/huaweicloud-core/skills/huawei-voucher/SKILL.md +13 -9
- package/plugins/huaweicloud-core/src/safety-policy.mjs +6 -2
- package/plugins/huaweicloud-core/src/sandbox/hwlink-api.mjs +75 -12
- package/plugins/huaweicloud-core/src/sandbox/session-manager.mjs +396 -9
- package/plugins/huaweicloud-core/src/tools.mjs +258 -5
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Full-Stack on the Sandbox: PostgreSQL 16 + PostgREST + Nginx
|
|
2
|
+
|
|
3
|
+
A REST API over a real database, fully inside the sandbox: PostgREST generates
|
|
4
|
+
a RESTful API from the PostgreSQL schema; nginx serves a frontend and
|
|
5
|
+
reverse-proxies `/api` to PostgREST on a single origin (no CORS needed).
|
|
6
|
+
All values below are **measured** on HCE 2.0 (aarch64, no systemd, dnf) in the
|
|
7
|
+
2026-10 test. Nginx templates: see `nginx-templates.md`. Tunnel exposure:
|
|
8
|
+
see `tunnel-persistence.md`.
|
|
9
|
+
|
|
10
|
+
## Architecture
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
browser → tunnel URL → nginx :8080
|
|
14
|
+
├─ / → static frontend (index.html)
|
|
15
|
+
└─ /api/ → proxy_pass http://127.0.0.1:3000 (PostgREST)
|
|
16
|
+
└─ PostgreSQL 16 :5432 → schema api
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Step 1: PostgreSQL 16 on HCE 2.0 (the hard part)
|
|
20
|
+
|
|
21
|
+
The OS repo only ships PostgreSQL 13, but PostgREST 16.x **requires PG ≥ 14**
|
|
22
|
+
(it refuses with "Cannot run in this PostgreSQL version, PostgREST needs at
|
|
23
|
+
least 14.0"). PGDG is the source for PG 16 — but HCE is NOT el8-lib-compatible:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# PGDG repo RPM fails on HCE ("nothing provides /etc/redhat-release") — write the repo file by hand.
|
|
27
|
+
# The el8 path is rhel-8-aarch64 WITH A HYPHEN: rhel8-aarch64 (no hyphen) 404s.
|
|
28
|
+
cat > /etc/yum.repos.d/pgdg16.repo << 'PGDG_EOF'
|
|
29
|
+
[pgdg16]
|
|
30
|
+
name=PostgreSQL 16 for RHEL8 aarch64
|
|
31
|
+
baseurl=https://download.postgresql.org/pub/repos/yum/16/redhat/rhel-8-aarch64/
|
|
32
|
+
enabled=1
|
|
33
|
+
gpgcheck=0
|
|
34
|
+
PGDG_EOF
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The PGDG el8 RPMs need RHEL-8-era libraries HCE does not have. el9 RPMs fail
|
|
38
|
+
differently (they want `libldap-2.5.so.0`). Install the el8 RPMs with
|
|
39
|
+
`--nodeps` and shim the sonames:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
BASE="https://download.postgresql.org/pub/repos/yum/16/redhat/rhel-8-aarch64"
|
|
43
|
+
# Download with resume (--max-time + -C -): GitHub/pgdn downloads from the CN
|
|
44
|
+
# region stall; a partial RPM fails rpm verification and ABORTS the whole transaction,
|
|
45
|
+
# silently skipping the other RPMs in the same rpm -ivh call. Install one RPM per call.
|
|
46
|
+
curl -fsSL --http1.1 --max-time 240 -C - "$BASE/postgresql16-libs-16.15-1PGDG.rhel8.10.aarch64.rpm" -o /tmp/pg16-libs.rpm
|
|
47
|
+
rpm -ivh --nodeps /tmp/pg16-libs.rpm
|
|
48
|
+
curl -fsSL --http1.1 --max-time 240 -C - "$BASE/postgresql16-16.15-1PGDG.rhel8.10.aarch64.rpm" -o /tmp/pg16.rpm
|
|
49
|
+
rpm -ivh --nodeps /tmp/pg16.rpm
|
|
50
|
+
curl -fsSL --http1.1 --max-time 240 -C - "$BASE/postgresql16-server-16.15-1PGDG.rhel8.10.aarch64.rpm" -o /tmp/pg16-server.rpm
|
|
51
|
+
rpm -ivh --nodeps /tmp/pg16-server.rpm
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Soname shims (measured 2026-10):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# ICU: versioned symbols (u_strToLower_60) make symlinks IMPOSSIBLE — a
|
|
58
|
+
# libicu.so.60 -> .69 symlink loads but crashes with "undefined symbol".
|
|
59
|
+
# Install the REAL el8 libicu60 (different sonames, coexists with HCE's icu69):
|
|
60
|
+
curl -fsSL --http1.1 --max-time 240 -C - \
|
|
61
|
+
"https://repo.almalinux.org/almalinux/8/BaseOS/aarch64/os/Packages/libicu-60.3-2.el8_1.aarch64.rpm" -o /tmp/libicu60.rpm
|
|
62
|
+
rpm -ivh --nodeps --force /tmp/libicu60.rpm # --force: license file conflicts with icu69
|
|
63
|
+
|
|
64
|
+
# LDAP: postgres only uses it for LDAP auth (not needed here) — symlinks suffice.
|
|
65
|
+
ln -sf /usr/lib64/libldap.so.2.0.200 /usr/lib64/libldap-2.4.so.2
|
|
66
|
+
ln -sf /usr/lib64/libldap.so.2.0.200 /usr/lib64/libldap_r-2.4.so.2 # initdb needs the _r soname
|
|
67
|
+
ln -sf /usr/lib64/liblber.so.2.0.200 /usr/lib64/liblber-2.4.so.2
|
|
68
|
+
|
|
69
|
+
# readline: psql links libreadline.so.7 (el8); HCE has .8 — API-compatible, symlink fine.
|
|
70
|
+
ln -sf /usr/lib64/libreadline.so.8 /usr/lib64/libreadline.so.7
|
|
71
|
+
|
|
72
|
+
ldconfig # registers /usr/pgsql-16/lib (libpq.so.5)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Init and start (PG refuses root; no systemd on HCE):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
mkdir -p /var/lib/pgsql/16/data && chown postgres:postgres /var/lib/pgsql/16/data
|
|
79
|
+
su - postgres -c "/usr/pgsql-16/bin/initdb -D /var/lib/pgsql/16/data"
|
|
80
|
+
su - postgres -c "/usr/pgsql-16/bin/pg_ctl -D /var/lib/pgsql/16/data -l /var/lib/pgsql/16/logfile start"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Step 2: PostgREST (static build) + schema
|
|
84
|
+
|
|
85
|
+
PostgREST 16.4 `linux-static-aarch64` runs on HCE out of the box (musl
|
|
86
|
+
static). **Dynamic Ubuntu builds do NOT work** — HCE glibc 2.34 is older than
|
|
87
|
+
Ubuntu 22.04's 2.35 and the binaries fail on load.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# https://github.com/PostgREST/postgrest/releases → postgrest-v16.4.0-linux-static-aarch64.tar.xz
|
|
91
|
+
mkdir -p /opt/postgrest && tar -xJf postgrest-*.tar.xz -C /opt/postgrest
|
|
92
|
+
|
|
93
|
+
cat > /opt/postgrest/pgrst.conf << 'PGRST_EOF'
|
|
94
|
+
db-uri = "postgres://api_user:<db-password>@localhost:5432/postgres"
|
|
95
|
+
db-schemas = "api"
|
|
96
|
+
db-anon-role = "api_user"
|
|
97
|
+
server-host = "127.0.0.1"
|
|
98
|
+
server-port = 3000
|
|
99
|
+
PGRST_EOF
|
|
100
|
+
|
|
101
|
+
# Schema + read-only API role (the anon role gates all writes)
|
|
102
|
+
su - postgres -c "/usr/pgsql-16/bin/psql -c \"CREATE SCHEMA api; CREATE TABLE api.todos (id serial primary key, done boolean not null default false, task text not null); CREATE ROLE api_user LOGIN PASSWORD '<db-password>'; GRANT USAGE ON SCHEMA api TO api_user; GRANT SELECT ON ALL TABLES IN SCHEMA api TO api_user;\""
|
|
103
|
+
|
|
104
|
+
setsid nohup /opt/postgrest/postgrest /opt/postgrest/pgrst.conf > /tmp/pgrst.log 2>&1 < /dev/null &
|
|
105
|
+
disown
|
|
106
|
+
curl -s http://127.0.0.1:3000/todos # [{"id":1,...}]
|
|
107
|
+
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/ # OpenAPI root, 200
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
PostgREST allows all CORS origins by default (`Access-Control-Allow-Origin: *`).
|
|
111
|
+
|
|
112
|
+
## Step 3: Nginx single-origin (frontend + /api)
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
cat > /etc/nginx/conf.d/app.conf << 'NGINX_EOF'
|
|
116
|
+
server {
|
|
117
|
+
listen <port>;
|
|
118
|
+
root /var/www/<app>;
|
|
119
|
+
index index.html;
|
|
120
|
+
location /api/ {
|
|
121
|
+
proxy_pass http://127.0.0.1:3000/; # trailing slashes strip the /api prefix
|
|
122
|
+
proxy_set_header Host $host;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
NGINX_EOF
|
|
126
|
+
nginx -t && nginx -s reload 2>/dev/null || nginx
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
TRAP (measured 2026-10): nginx runs as the `nginx` user but the frontend dir is
|
|
130
|
+
root-owned — `/` returns **403** until the traverse bit is set on the whole
|
|
131
|
+
path chain: `chmod o+x /var/www /var/www/<app>` (and `o+r` on the files).
|
|
132
|
+
This mirrors the deploy_nginx project-path permission fix.
|
|
133
|
+
|
|
134
|
+
Frontend pages fetch backend data same-origin: `fetch('/api/todos?order=id.asc')`
|
|
135
|
+
— no CORS, no absolute URLs, no CDN dependencies (keep pages self-contained;
|
|
136
|
+
CN-region CDNs stall sandbox egress).
|
|
137
|
+
|
|
138
|
+
## Step 4: Expose + keep alive
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# Port on the tunnel must be the ACTUAL nginx port
|
|
142
|
+
devbridge host -p <port> -e 720
|
|
143
|
+
# Then install the watchdog (renewal + host re-attach + health check)
|
|
144
|
+
# → huaweicloud_sandbox_keepalive_watchdog / references/tunnel-persistence.md
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Verify end-to-end through the public URL: `GET /` (page, 200), `GET /api/todos`
|
|
148
|
+
(data, 200), `POST /api/todos` (401 — the anon role has SELECT only; if a write
|
|
149
|
+
API is wanted, grant INSERT/UPDATE on the specific table, never to anon broadly).
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Tunnel Persistence: Renewal, Re-attach, and the Keepalive Watchdog
|
|
2
|
+
|
|
3
|
+
DevBridge tunnel URLs are bound to the tunnel ID, not to the host process. The
|
|
4
|
+
URL `https://<tunnel-id>-<port>.devbridge-s2.hwtunnel.com` keeps working as long
|
|
5
|
+
as (a) the tunnel has lifetime left and (b) a `devbridge host` process is
|
|
6
|
+
attached. Persistence is therefore about **preventing expiry, not recovering
|
|
7
|
+
URLs** — a tunnel whose record is lost cannot be recovered with the same URL.
|
|
8
|
+
|
|
9
|
+
All values below are **measured** (2026-10 tunnel-persistence test), not
|
|
10
|
+
inferred. Plugin automation: `huaweicloud_sandbox_renew_tunnel` and
|
|
11
|
+
`huaweicloud_sandbox_keepalive_watchdog`.
|
|
12
|
+
|
|
13
|
+
## Rolling-window expiry (measured)
|
|
14
|
+
|
|
15
|
+
The `-e <hours>` expiry is a **rolling window**: renewal sets
|
|
16
|
+
`expiration = now + hours` regardless of remaining time.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# 14:15Z: create with a 1-hour window
|
|
20
|
+
devbridge host -p 8080 -e 1 # Tunnel URL: https://nv2dvv6w-8080...
|
|
21
|
+
# 14:25Z: renew 10 minutes later
|
|
22
|
+
devbridge update nv2dvv6w -e 1
|
|
23
|
+
# expiration = 15:25Z = renewal time + 1h (NOT original expiry + 1h)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Consequences:
|
|
27
|
+
|
|
28
|
+
- Renewing **before** expiry keeps the same URL alive indefinitely — the
|
|
29
|
+
"permanent link" pattern is daily renewal, never URL recovery.
|
|
30
|
+
- `-e` accepts **1..720** hours (720 = 30 days, exact `+2592001s`). Larger
|
|
31
|
+
values are rejected.
|
|
32
|
+
|
|
33
|
+
## Reading the absolute expiration
|
|
34
|
+
|
|
35
|
+
Non-verbose `show` prints a relative value only; `-v` surfaces the Unix
|
|
36
|
+
timestamp:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
devbridge show nv2dvv6w -v | grep -oE 'tunnelExpiration[^0-9]+[0-9]+'
|
|
40
|
+
# tunnelExpiration":1795022391 (UTC seconds; ~30 days after a 720h renew)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Host re-attach: same tunnel, same URL
|
|
44
|
+
|
|
45
|
+
If the host process dies (sandbox restart, session teardown), re-attach with the
|
|
46
|
+
tunnel ID — the URL does **not** change:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
devbridge host <tunnel-id>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Always launch in the session-detached form so the host survives the exec
|
|
53
|
+
session: `setsid nohup devbridge host <id> ... < /dev/null &` + `disown`
|
|
54
|
+
(see the expose flow for why all three matter).
|
|
55
|
+
|
|
56
|
+
## Three-layer keepalive watchdog (the proven pattern)
|
|
57
|
+
|
|
58
|
+
Layer 1 keeps the host process alive, layer 2 keeps the lifetime topped up
|
|
59
|
+
daily (29 days of tolerance if renewal ever stops firing), layer 3 detects a
|
|
60
|
+
dead URL within 5 minutes. Install via
|
|
61
|
+
`huaweicloud_sandbox_keepalive_watchdog`, or by hand:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
cat > /tmp/tunnel-keepalive.sh << 'KEEPALIVE_EOF'
|
|
65
|
+
#!/bin/bash
|
|
66
|
+
# /workspace/.local/bin FIRST — it survives sandbox reboots; ~/.huawei/bin is
|
|
67
|
+
# on the system disk and is wiped back to the image state on reboot (#999).
|
|
68
|
+
export PATH="/workspace/.local/bin:$HOME/.huawei/bin:$PATH"
|
|
69
|
+
# entries: tunnelId:url — URLs HARDCODED, never derived from port list (see trap 2)
|
|
70
|
+
TUNNELS="<tunnel-id>:https://<tunnel-id>-<port>.devbridge-s2.hwtunnel.com"
|
|
71
|
+
declare -A LAST_RENEW
|
|
72
|
+
while true; do
|
|
73
|
+
NOW=$(date -u +%s); TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
74
|
+
for entry in $TUNNELS; do
|
|
75
|
+
ID="${entry%%:*}"; URL="${entry#*:}"
|
|
76
|
+
# Layer 1: host watchdog — re-attach keeps the URL (bound to tunnel ID)
|
|
77
|
+
if ! pgrep -f "devbridge host $ID" > /dev/null 2>&1; then
|
|
78
|
+
setsid nohup devbridge host "$ID" > "/tmp/host-$ID.log" 2>&1 < /dev/null &
|
|
79
|
+
echo "$TS host $ID restarted" >> /tmp/tunnel-keepalive.log; sleep 8
|
|
80
|
+
fi
|
|
81
|
+
# Layer 2: daily renewal (86400s) — rolling window keeps the URL alive
|
|
82
|
+
if [ $((${LAST_RENEW[$ID]:-0})) -eq 0 ] || [ $((NOW - ${LAST_RENEW[$ID]})) -ge 86400 ]; then
|
|
83
|
+
devbridge update "$ID" -e 720 >> /tmp/tunnel-keepalive.log 2>&1 \
|
|
84
|
+
&& echo "$TS renew $ID ok (720h)" >> /tmp/tunnel-keepalive.log \
|
|
85
|
+
|| echo "$TS renew $ID FAILED" >> /tmp/tunnel-keepalive.log
|
|
86
|
+
LAST_RENEW[$ID]=$NOW
|
|
87
|
+
fi
|
|
88
|
+
# Layer 3: health check every 5 min (loop sleep), log on non-200/304
|
|
89
|
+
HC=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$URL/" 2>/dev/null || echo "000")
|
|
90
|
+
[ "$HC" != "200" ] && [ "$HC" != "304" ] \
|
|
91
|
+
&& echo "$TS health $ID WARN http=$HC" >> /tmp/tunnel-keepalive.log
|
|
92
|
+
done
|
|
93
|
+
sleep 300
|
|
94
|
+
done
|
|
95
|
+
KEEPALIVE_EOF
|
|
96
|
+
chmod +x /tmp/tunnel-keepalive.sh
|
|
97
|
+
setsid nohup /tmp/tunnel-keepalive.sh > /dev/null 2>&1 < /dev/null &
|
|
98
|
+
disown
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The pgrep pattern MUST be tunnel-specific (`devbridge host $ID`) — a bare
|
|
102
|
+
`pgrep -f "devbridge host"` matches _any_ tunnel's host and silently skips
|
|
103
|
+
re-attaching the dead one (measured 2026-10).
|
|
104
|
+
|
|
105
|
+
## Traps (all measured)
|
|
106
|
+
|
|
107
|
+
1. **Health-check URL must match the served content.** The watchdog checks
|
|
108
|
+
`$URL/` — checking `$URL/todos` against a static page server logs a false
|
|
109
|
+
404 WARN on every tick. Use the path that actually returns 200/304.
|
|
110
|
+
2. **Never build the health URL from `devbridge port list` output.** The table
|
|
111
|
+
lines are wrapped in OSC escape sequences, so anchored greps
|
|
112
|
+
(`^\S+ \K[0-9]+`) silently fail and the constructed URL 404s. Hardcode the
|
|
113
|
+
URL per tunnel — tunnel IDs never change, so the URL is stable.
|
|
114
|
+
3. **The management plane can lose a tunnel record while the data plane still
|
|
115
|
+
serves.** `devbridge show <id>` / `devbridge update <id>` return
|
|
116
|
+
`10002 tunnel not found` even though the URL keeps working (host attached).
|
|
117
|
+
The tunnel then cannot be renewed or inspected — treat it as disposable and
|
|
118
|
+
create a fresh tunnel for anything that must survive.
|
|
119
|
+
4. **Renewal success = exit 0 AND absolute expiration visible.** A bare exit
|
|
120
|
+
code from a still-running host process says nothing about the management
|
|
121
|
+
record. Verify with `show <id> -v` (see `huaweicloud_sandbox_renew_tunnel`).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: huawei-voucher
|
|
3
3
|
description: '查询和领取华为云激励代金券(一人一次);领取华为开发者大赛·小红书skill创新赛专属代金券(需已完成赛事报名)。激励代金券:当用户首次使用、开始会话、询问插件能力,或提到"领券/代金券/优惠券/激励金/领取/福利"等话题时,先调 voucher_status 查领取状态:未领取(claimed=false)则主动提示可领取一张代金券并询问是否领取;已领取(claimed=true)或状态不明确则静默不打扰。赛事代金券:仅在用户明确要求领取大赛代金券时调用 competition_voucher_claim,不要主动提示。Triggers: 首次使用, 会话开始, voucher, coupon, incentive, 领券, 代金券, 优惠券, 激励金, 领取, 福利, 华为开发者大赛, 小红书, skill创新赛, 赛事代金券, 大赛代金券, 比赛领券. NOT for: 账单/费用查询(用 huawei-billing)。'
|
|
4
|
-
version:
|
|
4
|
+
version: 3
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# 华为云代金券
|
|
@@ -43,14 +43,16 @@ version: 2
|
|
|
43
43
|
|
|
44
44
|
### 3. 结果解读(错误码 UX)
|
|
45
45
|
|
|
46
|
-
| 返回 message 关键特征 | 含义 | 对用户表述
|
|
47
|
-
| ----------------------------------------------------------------------- | --------------------------- |
|
|
48
|
-
| `领取成功` | 发券成功 | 告知券已到账
|
|
49
|
-
| `已领取过` / `已领取` | 该账号已领过 | 告知已领取,不打扰
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
46
|
+
| 返回 message 关键特征 | 含义 | 对用户表述 |
|
|
47
|
+
| ----------------------------------------------------------------------- | --------------------------- | ---------------------------- |
|
|
48
|
+
| `领取成功` | 发券成功 | 告知券已到账 |
|
|
49
|
+
| `已领取过` / `已领取` | 该账号已领过 | 告知已领取,不打扰 |
|
|
50
|
+
| `code=HDKIT_VOUCHER_NOT_CONFIGURED`(代金券服务配置缺失…) | 服务端未配置有效期天数 | 告知代金券暂不可用,稍后再试 |
|
|
51
|
+
| `code=HDKIT_VOUCHER_EXPIRE_INVALID`(代金券失效时间配置异常…) | 失效时间配置非法 | 告知代金券暂不可用,稍后再试 |
|
|
52
|
+
| `本月代金券总额度已用完,所有账号均无法领取` | 全局额度用尽(HD.60630042) | 告知下月再试 |
|
|
53
|
+
| `请先完成实名认证` | 账号未实名(HD.60630022) | 引导去控制台实名认证 |
|
|
54
|
+
| `发券失败: <原文>` | 其他错误 | 原样转达,不展开 |
|
|
55
|
+
| `code=HDKIT_CRED_*` / 含「未配置 / not configured / AK/SK 无效 / 限额」 | 凭证未配置、无效或达限额 | 引导用户检查并配置 AK/SK |
|
|
54
56
|
|
|
55
57
|
## 赛事代金券(华为开发者大赛·小红书skill创新赛)
|
|
56
58
|
|
|
@@ -75,6 +77,8 @@ version: 2
|
|
|
75
77
|
| `已领取过` / `已领取` | 该账号已领过赛事券 | 告知已领取,不打扰 |
|
|
76
78
|
| `code=HDKIT_COMPETITION_NOT_FOUND`(赛事不存在或已下线) | 赛事配置异常 | 告知赛事暂不可用,稍后再试 |
|
|
77
79
|
| `code=HDKIT_COMPETITION_NOT_CONFIGURED`(赛事代金券服务配置缺失…) | 服务端活动参数未配置 | 告知赛事代金券暂未开放,稍后再试 |
|
|
80
|
+
| `code=HDKIT_VOUCHER_NOT_CONFIGURED`(代金券服务配置缺失…) | 服务端未配置有效期天数 | 告知代金券暂不可用,稍后再试 |
|
|
81
|
+
| `code=HDKIT_VOUCHER_EXPIRE_INVALID`(代金券失效时间配置异常…) | 失效时间配置非法 | 告知代金券暂不可用,稍后再试 |
|
|
78
82
|
| `本月代金券总额度已用完,所有账号均无法领取` | 全局额度用尽(HD.60630042) | 告知下月再试 |
|
|
79
83
|
| `请先完成实名认证` | 账号未实名(HD.60630022) | 引导去控制台实名认证 |
|
|
80
84
|
| `code=HDKIT_CRED_*` / 含「未配置 / not configured / AK/SK 无效 / 限额」 | 凭证未配置、无效或达限额 | 引导用户检查并配置 AK/SK |
|
|
@@ -297,8 +297,12 @@ export function classifyHcloudArgs(args, options = {}) {
|
|
|
297
297
|
'chattri',
|
|
298
298
|
'bucketpolicy',
|
|
299
299
|
'lifecycle',
|
|
300
|
-
'cors'
|
|
301
|
-
|
|
300
|
+
// #1001: 'cors' and 'website' were removed — KooCLI 7.x's OBS wrapper does
|
|
301
|
+
// NOT expose them (`hcloud OBS help cors` → "No such command"; same for
|
|
302
|
+
// website). A nonexistent command cannot produce a write side effect, so
|
|
303
|
+
// grading it as a write was safety based on a stale assumption. CORS/static-
|
|
304
|
+
// website config goes through the REST API (PUT /?cors) — see the
|
|
305
|
+
// huawei-obs skill common-pitfalls.md CORS chapter.
|
|
302
306
|
'sign',
|
|
303
307
|
'share-add',
|
|
304
308
|
'share-update',
|
|
@@ -119,20 +119,41 @@ async function apiPost(path, body, ak, sk, securitytoken) {
|
|
|
119
119
|
return { status: resp.status, data: await resp.json() };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
122
|
+
// Transient/restriction-class error codes from the connection API.
|
|
123
|
+
// HDKIT_SUB_ACCOUNT_RESTRICTED and HD.83700029 are the SAME server-side
|
|
124
|
+
// transient condition under two different codes (measured 2026-10-21/22: the
|
|
125
|
+
// same AK/SK and the same request body got HD.83700029 on the 21st and
|
|
126
|
+
// HDKIT_SUB_ACCOUNT_RESTRICTED on the 22nd — the code drifts between calls).
|
|
127
|
+
// The message accuses the account of being a restricted sub-account, but a
|
|
128
|
+
// fully compliant account (realnameVerified:true, agreementSigned:true, no
|
|
129
|
+
// sub-users) got the SAME error and the identical request succeeded 25 minutes
|
|
130
|
+
// later — the only action that helped was waiting. Retry these instead of
|
|
131
|
+
// failing, and NEVER surface the server's "switch to the main account" advice
|
|
132
|
+
// as actionable: acting on it is an irreversible, account-wide operation
|
|
133
|
+
// triggered by a transient state.
|
|
134
|
+
const TRANSIENT_CONNECTION_CODES = new Set(['HDKIT_SUB_ACCOUNT_RESTRICTED', 'HD.83700029']);
|
|
135
|
+
|
|
136
|
+
// Classify a connection-API failure (exported for tests). Returns
|
|
137
|
+
// 'transient_restricted' / 'retryable_server' (retried with backoff) or
|
|
138
|
+
// 'fatal' (fails fast, no retry).
|
|
139
|
+
export function classifyConnectionError(data, status) {
|
|
140
|
+
const code = String(data?.error_code || data?.code || '').toUpperCase();
|
|
141
|
+
if (TRANSIENT_CONNECTION_CODES.has(code)) return 'transient_restricted';
|
|
142
|
+
if (status === 429 || (status >= 500 && status < 600)) return 'retryable_server';
|
|
143
|
+
return 'fatal';
|
|
144
|
+
}
|
|
130
145
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
146
|
+
// Default backoff between connection retries, in ms. The measured incident
|
|
147
|
+
// self-resolved in ~25 minutes, so three spaced retries cover the short end
|
|
148
|
+
// without blocking exec callers for long.
|
|
149
|
+
export const CONNECTION_RETRY_BACKOFF_MS = [1000, 4000, 10000];
|
|
134
150
|
|
|
135
|
-
|
|
151
|
+
const CONNECTION_RETRY_HINT =
|
|
152
|
+
'This is a TRANSIENT server-side state, NOT an account problem: the same request typically succeeds after a short wait ' +
|
|
153
|
+
'(measured 2026-10-22: HDKIT_SUB_ACCOUNT_RESTRICTED self-resolved in 25 minutes for a fully compliant account — realname verified, agreement signed, no sub-users). ' +
|
|
154
|
+
'Do NOT switch to the main account, do NOT modify IAM users or credentials based on this error — retry with backoff instead.';
|
|
155
|
+
|
|
156
|
+
async function waitForConnection(connectionId, envId, ak, sk, securitytoken) {
|
|
136
157
|
const maxAttempts = 60;
|
|
137
158
|
|
|
138
159
|
for (let i = 0; i < maxAttempts; i++) {
|
|
@@ -159,3 +180,45 @@ export async function createConnection(envId, ak, sk, securitytoken) {
|
|
|
159
180
|
|
|
160
181
|
throw new Error(`Timed out waiting for connection ${connectionId}`);
|
|
161
182
|
}
|
|
183
|
+
|
|
184
|
+
export async function createConnection(envId, ak, sk, securitytoken, { maxRetries = 3, backoffMs } = {}) {
|
|
185
|
+
const backoff = backoffMs || CONNECTION_RETRY_BACKOFF_MS;
|
|
186
|
+
const maxAttempts = maxRetries + 1;
|
|
187
|
+
let lastFailure = null;
|
|
188
|
+
|
|
189
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
190
|
+
if (attempt > 1) {
|
|
191
|
+
const wait = backoff[Math.min(attempt - 2, backoff.length - 1)];
|
|
192
|
+
process.stderr.write(
|
|
193
|
+
`createConnection: retryable error (${lastFailure.kind}) — retry ${attempt - 1}/${maxRetries} in ${wait}ms\n`,
|
|
194
|
+
);
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
196
|
+
}
|
|
197
|
+
const { status, data } = await apiPost(
|
|
198
|
+
`/open-api-public/v1/devenvs/${envId}/connections`,
|
|
199
|
+
{ source: 'CLI' },
|
|
200
|
+
ak,
|
|
201
|
+
sk,
|
|
202
|
+
securitytoken,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (status === 200 && data?.error_code === '0000' && data?.result?.connection_id) {
|
|
206
|
+
return await waitForConnection(data.result.connection_id, envId, ak, sk, securitytoken);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const kind = classifyConnectionError(data, status);
|
|
210
|
+
if (kind === 'fatal') {
|
|
211
|
+
throw new Error(`Failed to create connection: ${JSON.stringify(data)}`);
|
|
212
|
+
}
|
|
213
|
+
lastFailure = { kind, status, data };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const err = new Error(
|
|
217
|
+
`Failed to create connection after ${maxAttempts} attempts (${lastFailure.kind}): ${JSON.stringify(lastFailure.data)}. ${CONNECTION_RETRY_HINT}`,
|
|
218
|
+
);
|
|
219
|
+
err.code = String(lastFailure.data?.error_code || `HTTP_${lastFailure.status}`);
|
|
220
|
+
err.kind = lastFailure.kind;
|
|
221
|
+
err.retryable = true;
|
|
222
|
+
err.suggestedRetryAfterMs = backoff[backoff.length - 1];
|
|
223
|
+
throw err;
|
|
224
|
+
}
|