This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/skywalking.git


The following commit(s) were added to refs/heads/master by this push:
     new 89624809f0 Migrate e2e admin-API curl calls to swctl admin commands 
(#13889)
89624809f0 is described below

commit 89624809f078b6424b474323ae091f72b9e694b4
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Thu Jun 4 09:18:58 2026 +0800

    Migrate e2e admin-API curl calls to swctl admin commands (#13889)
    
    * Migrate e2e admin-API curl calls to swctl admin commands
    
    Replace raw curl interactions with the OAP admin-server REST host (port 
17128)
    across the e2e suite with the new 'swctl admin ...' command tree
    (skywalking-cli #228, commit b447211):
    
    - runtime-rule flows (mal/lal/cluster) -> swctl admin runtime-rule ...
    - dsl-debug flows (oal/mal/lal-block/lal-statement) -> swctl admin 
dsl-debug ...
    - ui-management -> swctl admin ui-template ...
    - storage inspect/config(dump,ttl) -> swctl admin inspect|config ...
    
    --display json/yaml keeps response bodies parseable by the existing jq/yq
    assertions; runtime-rule negative paths assert the CLI's typed error 
envelope
    (HTTP <code> (<applyStatus>)). config-dump.yml is regenerated as the JSON
    string-map that 'admin config dump' returns. Bumps SW_CTL_COMMIT to the
    admin-capable commit. Non-admin curls (BanyanDB probe, self-telemetry, alarm
    webhook) are intentionally kept.
    
    * e2e: fix swctl admin review feedback (yaml.v2 key casing + missing swctl 
install)
    
    - inspect cases: the CLI marshals typed structs through yaml.v2 (json tags
      ignored -> lowercased keys scopeid/entityid/mqeentity), which would not 
match
      the existing fixtures (scopeId/entityId/mqeEntity). Use '--display json | 
yq -P'
      so the json tags are honored and the fixtures stay unchanged.
    - runtime-rule/cluster and ui-management/banyandb setups now install swctl: 
both
      cases newly depend on it (the flows/cases no longer use curl), but their 
setup
      only installed jq / yq, so a clean e2e shell would hit 'swctl: command 
not found'.
---
 .../dsl-debugging/lal-block/dsl-debug-flow.sh      |  30 +-
 test/e2e-v2/cases/dsl-debugging/lal-block/e2e.yaml |   2 +-
 .../dsl-debugging/lal-statement/dsl-debug-flow.sh  |  30 +-
 .../cases/dsl-debugging/lal-statement/e2e.yaml     |   2 +-
 .../cases/dsl-debugging/mal/dsl-debug-flow.sh      |  29 +-
 test/e2e-v2/cases/dsl-debugging/mal/e2e.yaml       |   2 +-
 .../cases/dsl-debugging/oal/dsl-debug-flow.sh      |  19 +-
 .../cases/runtime-rule/cluster/cluster-flow.sh     |  25 +-
 test/e2e-v2/cases/runtime-rule/cluster/e2e.yaml    |   4 +-
 test/e2e-v2/cases/runtime-rule/lal/e2e.yaml        |   2 +-
 test/e2e-v2/cases/runtime-rule/lal/lal-flow.sh     |  27 +-
 .../runtime-rule/mal-storage/banyandb/e2e.yaml     |   2 +-
 .../mal-storage/elasticsearch/e2e.yaml             |   2 +-
 .../runtime-rule/mal-storage/postgresql/e2e.yaml   |   2 +-
 .../runtime-rule/mal-storage/runtime-rule-flow.sh  | 180 +++++----
 test/e2e-v2/cases/storage/banyandb/e2e.yaml        |   2 +-
 test/e2e-v2/cases/storage/es/e2e.yaml              |   2 +-
 test/e2e-v2/cases/storage/es/es-sharding/e2e.yaml  |   2 +-
 test/e2e-v2/cases/storage/expected/config-dump.yml | 428 +++++++++++----------
 test/e2e-v2/cases/storage/mysql/e2e.yaml           |   5 +-
 test/e2e-v2/cases/storage/opensearch/e2e.yaml      |   2 +-
 test/e2e-v2/cases/storage/postgres/e2e.yaml        |   2 +-
 test/e2e-v2/cases/storage/storage-cases.yaml       |  35 +-
 test/e2e-v2/cases/ui-management/banyandb/e2e.yaml  |   4 +-
 .../cases/ui-management/ui-management-cases.yaml   |  69 ++--
 test/e2e-v2/script/env                             |   2 +-
 26 files changed, 475 insertions(+), 436 deletions(-)

diff --git a/test/e2e-v2/cases/dsl-debugging/lal-block/dsl-debug-flow.sh 
b/test/e2e-v2/cases/dsl-debugging/lal-block/dsl-debug-flow.sh
index b22cd0fbfc..ceeff95a2b 100755
--- a/test/e2e-v2/cases/dsl-debugging/lal-block/dsl-debug-flow.sh
+++ b/test/e2e-v2/cases/dsl-debugging/lal-block/dsl-debug-flow.sh
@@ -39,6 +39,11 @@ OAP_HOST="${OAP_HOST:-127.0.0.1}"
 OAP_REST_PORT="${OAP_REST_PORT:-17128}"
 OAP_BASE="http://${OAP_HOST}:${OAP_REST_PORT}";
 
+# All admin-server REST calls go through swctl's `admin` command tree instead 
of
+# raw curl. `--display json` keeps the body shape identical to the old curl
+# output, so the downstream jq assertions are unchanged.
+admin() { swctl --display json --admin-url="${OAP_BASE}" admin "$@"; }
+
 SETTLE_SECONDS="${SETTLE_SECONDS:-180}"
 
 # Reuse the same multi-statement seed the statement case uses, so comparing
@@ -57,27 +62,25 @@ CLIENT_ID="e2e-dsldbg-lal-block-1"
 
 log "waiting for OAP admin port"
 deadline=$(( $(date +%s) + 90 ))
-until curl -fsS "${OAP_BASE}/dsl-debugging/status" >/dev/null 2>&1; do
+until admin dsl-debug status >/dev/null 2>&1; do
     if [ "$(date +%s)" -ge "${deadline}" ]; then fail "OAP admin not ready 
after 90s"; fi
     sleep 2
 done
 
 # --- Phase 0: status check 
------------------------------------------------------------
-log "=== Phase 0: /dsl-debugging/status ==="
-status_body="$(curl -fsS "${OAP_BASE}/dsl-debugging/status")"
+log "=== Phase 0: dsl-debug status ==="
+status_body="$(admin dsl-debug status)"
 echo "${status_body}" | jq -e '.injectionEnabled == true' >/dev/null \
     || fail "injectionEnabled is not true"
 
 # --- Phase 1: apply runtime-rule LAL 
--------------------------------------------------
 log "=== Phase 1: apply runtime-rule LAL seed (shared with lal-statement case) 
==="
-curl -fsS -XPOST -H 'Content-Type: text/plain' \
-    --data-binary "@${SEED_LAL}" \
-    
"${OAP_BASE}/runtime/rule/addOrUpdate?catalog=${RR_CATALOG}&name=${RR_NAME}" 
>/dev/null \
+admin runtime-rule add --catalog "${RR_CATALOG}" --name "${RR_NAME}" -f 
"${SEED_LAL}" >/dev/null \
     || fail "addOrUpdate ${RR_CATALOG}/${RR_NAME} failed"
 deadline=$(( $(date +%s) + 60 ))
 status=""
 while (( $(date +%s) < deadline )); do
-    status="$(curl -fsS "${OAP_BASE}/runtime/rule/list" 2>/dev/null \
+    status="$(admin runtime-rule list 2>/dev/null \
         | jq -r --arg c "${RR_CATALOG}" --arg n "${RR_NAME}" \
             '.rules[] | select(.catalog == $c and .name == $n) | .status' | 
head -1)"
     [ "${status}" = "ACTIVE" ] && break
@@ -88,8 +91,9 @@ log "✓ seed applied: ${RR_CATALOG}/${RR_NAME} ACTIVE"
 
 # --- Phase 2: install with granularity=block (default) 
--------------------------------
 log "=== Phase 2: install session with granularity=block ==="
-install_body="$(curl -fsS -XPOST \
-    
"${OAP_BASE}/dsl-debugging/session?catalog=${DBG_CATALOG}&name=${DBG_NAME}&ruleName=${DBG_RULE_NAME}&clientId=${CLIENT_ID}&granularity=block")"
+install_body="$(admin dsl-debug session start \
+    --catalog "${DBG_CATALOG}" --name "${DBG_NAME}" --rule-name 
"${DBG_RULE_NAME}" \
+    --client-id "${CLIENT_ID}" --granularity block)"
 log "  install → ${install_body}"
 SESSION_ID="$(echo "${install_body}" | jq -r '.sessionId // empty')"
 [ -n "${SESSION_ID}" ] || fail "install did not return sessionId"
@@ -103,7 +107,7 @@ deadline=$(( $(date +%s) + SETTLE_SECONDS ))
 records_count=0
 collect_body=""
 while (( $(date +%s) < deadline )); do
-    collect_body="$(curl -fsS 
"${OAP_BASE}/dsl-debugging/session/${SESSION_ID}")"
+    collect_body="$(admin dsl-debug session get "${SESSION_ID}")"
     records_count="$(echo "${collect_body}" | jq '[.nodes[].records[]] | 
length')"
     if [ "${records_count}" -gt 0 ]; then
         # Block mode: a fully walked record has at minimum input + output 
samples.
@@ -160,13 +164,13 @@ log "✓ block-mode shape valid (${records_count} records; 
per-statement probes
 
 # --- Phase 6: stop session 
------------------------------------------------------------
 log "=== Phase 6: stop session ==="
-stop_body="$(curl -fsS -XPOST 
"${OAP_BASE}/dsl-debugging/session/${SESSION_ID}/stop")"
+stop_body="$(admin dsl-debug session stop "${SESSION_ID}")"
 echo "${stop_body}" | jq -e '.localStopped == true' >/dev/null \
     || fail "localStopped != true"
 
 # --- Phase 7: cleanup runtime-rule 
----------------------------------------------------
 log "=== Phase 7: cleanup runtime-rule ==="
-curl -fsS -XPOST 
"${OAP_BASE}/runtime/rule/inactivate?catalog=${RR_CATALOG}&name=${RR_NAME}" 
>/dev/null || true
-curl -fsS -XPOST 
"${OAP_BASE}/runtime/rule/delete?catalog=${RR_CATALOG}&name=${RR_NAME}" 
>/dev/null || true
+admin runtime-rule inactivate --catalog "${RR_CATALOG}" --name "${RR_NAME}" 
>/dev/null || true
+admin runtime-rule delete --catalog "${RR_CATALOG}" --name "${RR_NAME}" 
>/dev/null || true
 
 log "=== ALL LAL BLOCK FLOW PHASES PASSED ==="
diff --git a/test/e2e-v2/cases/dsl-debugging/lal-block/e2e.yaml 
b/test/e2e-v2/cases/dsl-debugging/lal-block/e2e.yaml
index c7afbc6009..7d655867d1 100644
--- a/test/e2e-v2/cases/dsl-debugging/lal-block/e2e.yaml
+++ b/test/e2e-v2/cases/dsl-debugging/lal-block/e2e.yaml
@@ -52,7 +52,7 @@ verify:
     count: 1
     interval: 1s
   cases:
-    - query: curl -fsS http://127.0.0.1:17128/dsl-debugging/status >/dev/null 
&& echo ok
+    - query: swctl --display json --admin-url=http://127.0.0.1:17128 admin 
dsl-debug status >/dev/null && echo ok
       expected: expected/ok.txt
 
 cleanup:
diff --git a/test/e2e-v2/cases/dsl-debugging/lal-statement/dsl-debug-flow.sh 
b/test/e2e-v2/cases/dsl-debugging/lal-statement/dsl-debug-flow.sh
index 6ab7dcaea2..ff24bd6e1a 100755
--- a/test/e2e-v2/cases/dsl-debugging/lal-statement/dsl-debug-flow.sh
+++ b/test/e2e-v2/cases/dsl-debugging/lal-statement/dsl-debug-flow.sh
@@ -35,6 +35,11 @@ OAP_HOST="${OAP_HOST:-127.0.0.1}"
 OAP_REST_PORT="${OAP_REST_PORT:-17128}"
 OAP_BASE="http://${OAP_HOST}:${OAP_REST_PORT}";
 
+# All admin-server REST calls go through swctl's `admin` command tree instead 
of
+# raw curl. `--display json` keeps the body shape identical to the old curl
+# output, so the downstream jq assertions are unchanged.
+admin() { swctl --display json --admin-url="${OAP_BASE}" admin "$@"; }
+
 SETTLE_SECONDS="${SETTLE_SECONDS:-180}"
 
 
SEED_DIR="${SEED_DIR:-$(pwd)/test/e2e-v2/cases/dsl-debugging/lal-statement/seed-rules}"
@@ -50,27 +55,25 @@ CLIENT_ID="e2e-dsldbg-lal-stmt-1"
 
 log "waiting for OAP admin port"
 deadline=$(( $(date +%s) + 90 ))
-until curl -fsS "${OAP_BASE}/dsl-debugging/status" >/dev/null 2>&1; do
+until admin dsl-debug status >/dev/null 2>&1; do
     if [ "$(date +%s)" -ge "${deadline}" ]; then fail "OAP admin not ready 
after 90s"; fi
     sleep 2
 done
 
 # --- Phase 0: status check 
------------------------------------------------------------
-log "=== Phase 0: /dsl-debugging/status ==="
-status_body="$(curl -fsS "${OAP_BASE}/dsl-debugging/status")"
+log "=== Phase 0: dsl-debug status ==="
+status_body="$(admin dsl-debug status)"
 echo "${status_body}" | jq -e '.injectionEnabled == true' >/dev/null \
     || fail "injectionEnabled is not true"
 
 # --- Phase 1: apply runtime-rule LAL 
--------------------------------------------------
 log "=== Phase 1: apply runtime-rule LAL seed ==="
-curl -fsS -XPOST -H 'Content-Type: text/plain' \
-    --data-binary "@${SEED_LAL}" \
-    
"${OAP_BASE}/runtime/rule/addOrUpdate?catalog=${RR_CATALOG}&name=${RR_NAME}" 
>/dev/null \
+admin runtime-rule add --catalog "${RR_CATALOG}" --name "${RR_NAME}" -f 
"${SEED_LAL}" >/dev/null \
     || fail "addOrUpdate ${RR_CATALOG}/${RR_NAME} failed"
 deadline=$(( $(date +%s) + 60 ))
 status=""
 while (( $(date +%s) < deadline )); do
-    status="$(curl -fsS "${OAP_BASE}/runtime/rule/list" 2>/dev/null \
+    status="$(admin runtime-rule list 2>/dev/null \
         | jq -r --arg c "${RR_CATALOG}" --arg n "${RR_NAME}" \
             '.rules[] | select(.catalog == $c and .name == $n) | .status' | 
head -1)"
     [ "${status}" = "ACTIVE" ] && break
@@ -81,8 +84,9 @@ log "✓ seed applied: ${RR_CATALOG}/${RR_NAME} ACTIVE"
 
 # --- Phase 2: install with granularity=statement 
--------------------------------------
 log "=== Phase 2: install session with granularity=statement ==="
-install_body="$(curl -fsS -XPOST \
-    
"${OAP_BASE}/dsl-debugging/session?catalog=${DBG_CATALOG}&name=${DBG_NAME}&ruleName=${DBG_RULE_NAME}&clientId=${CLIENT_ID}&granularity=statement")"
+install_body="$(admin dsl-debug session start \
+    --catalog "${DBG_CATALOG}" --name "${DBG_NAME}" --rule-name 
"${DBG_RULE_NAME}" \
+    --client-id "${CLIENT_ID}" --granularity statement)"
 log "  install → ${install_body}"
 SESSION_ID="$(echo "${install_body}" | jq -r '.sessionId // empty')"
 [ -n "${SESSION_ID}" ] || fail "install did not return sessionId"
@@ -96,7 +100,7 @@ deadline=$(( $(date +%s) + SETTLE_SECONDS ))
 records_count=0
 collect_body=""
 while (( $(date +%s) < deadline )); do
-    collect_body="$(curl -fsS 
"${OAP_BASE}/dsl-debugging/session/${SESSION_ID}")"
+    collect_body="$(admin dsl-debug session get "${SESSION_ID}")"
     records_count="$(echo "${collect_body}" | jq '[.nodes[].records[]] | 
length')"
     if [ "${records_count}" -gt 0 ]; then
         # Wait for a record with several samples — statement mode produces one
@@ -162,13 +166,13 @@ log "✓ statement-mode shape valid (${records_count} 
records, ${distinct} disti
 
 # --- Phase 6: stop session 
------------------------------------------------------------
 log "=== Phase 6: stop session ==="
-stop_body="$(curl -fsS -XPOST 
"${OAP_BASE}/dsl-debugging/session/${SESSION_ID}/stop")"
+stop_body="$(admin dsl-debug session stop "${SESSION_ID}")"
 echo "${stop_body}" | jq -e '.localStopped == true' >/dev/null \
     || fail "localStopped != true"
 
 # --- Phase 7: cleanup runtime-rule 
----------------------------------------------------
 log "=== Phase 7: cleanup runtime-rule ==="
-curl -fsS -XPOST 
"${OAP_BASE}/runtime/rule/inactivate?catalog=${RR_CATALOG}&name=${RR_NAME}" 
>/dev/null || true
-curl -fsS -XPOST 
"${OAP_BASE}/runtime/rule/delete?catalog=${RR_CATALOG}&name=${RR_NAME}" 
>/dev/null || true
+admin runtime-rule inactivate --catalog "${RR_CATALOG}" --name "${RR_NAME}" 
>/dev/null || true
+admin runtime-rule delete --catalog "${RR_CATALOG}" --name "${RR_NAME}" 
>/dev/null || true
 
 log "=== ALL LAL STATEMENT FLOW PHASES PASSED ==="
diff --git a/test/e2e-v2/cases/dsl-debugging/lal-statement/e2e.yaml 
b/test/e2e-v2/cases/dsl-debugging/lal-statement/e2e.yaml
index 23fa7b7700..ee84a35c7d 100644
--- a/test/e2e-v2/cases/dsl-debugging/lal-statement/e2e.yaml
+++ b/test/e2e-v2/cases/dsl-debugging/lal-statement/e2e.yaml
@@ -51,7 +51,7 @@ verify:
     count: 1
     interval: 1s
   cases:
-    - query: curl -fsS http://127.0.0.1:17128/dsl-debugging/status >/dev/null 
&& echo ok
+    - query: swctl --display json --admin-url=http://127.0.0.1:17128 admin 
dsl-debug status >/dev/null && echo ok
       expected: expected/ok.txt
 
 cleanup:
diff --git a/test/e2e-v2/cases/dsl-debugging/mal/dsl-debug-flow.sh 
b/test/e2e-v2/cases/dsl-debugging/mal/dsl-debug-flow.sh
index 267234d760..7b2b051ebb 100755
--- a/test/e2e-v2/cases/dsl-debugging/mal/dsl-debug-flow.sh
+++ b/test/e2e-v2/cases/dsl-debugging/mal/dsl-debug-flow.sh
@@ -37,6 +37,11 @@ OAP_HOST="${OAP_HOST:-127.0.0.1}"
 OAP_REST_PORT="${OAP_REST_PORT:-17128}"
 OAP_BASE="http://${OAP_HOST}:${OAP_REST_PORT}";
 
+# All admin-server REST calls go through swctl's `admin` command tree instead 
of
+# raw curl. `--display json` keeps the body shape identical to the old curl
+# output, so the downstream jq assertions are unchanged.
+admin() { swctl --display json --admin-url="${OAP_BASE}" admin "$@"; }
+
 SETTLE_SECONDS="${SETTLE_SECONDS:-300}"
 
 SEED_DIR="${SEED_DIR:-$(pwd)/test/e2e-v2/cases/dsl-debugging/mal/seed-rules}"
@@ -53,26 +58,24 @@ CLIENT_ID="e2e-dsldbg-mal-1"
 
 log "waiting for OAP admin port"
 deadline=$(( $(date +%s) + 120 ))
-until curl -fsS "${OAP_BASE}/dsl-debugging/status" >/dev/null 2>&1; do
+until admin dsl-debug status >/dev/null 2>&1; do
     if [ "$(date +%s)" -ge "${deadline}" ]; then fail "OAP admin not ready 
after 120s"; fi
     sleep 2
 done
 
 # --- Phase 0: status 
------------------------------------------------------------------
-log "=== Phase 0: /dsl-debugging/status ==="
-curl -fsS "${OAP_BASE}/dsl-debugging/status" | jq -e '.injectionEnabled == 
true' >/dev/null \
+log "=== Phase 0: dsl-debug status ==="
+admin dsl-debug status | jq -e '.injectionEnabled == true' >/dev/null \
     || fail "injectionEnabled is not true"
 
 # --- Phase 1: apply runtime-rule MAL 
--------------------------------------------------
 log "=== Phase 1: apply runtime-rule MAL seed ==="
-curl -fsS -XPOST -H 'Content-Type: text/plain' \
-    --data-binary "@${SEED_MAL}" \
-    
"${OAP_BASE}/runtime/rule/addOrUpdate?catalog=${RR_CATALOG}&name=${RR_NAME}" 
>/dev/null \
+admin runtime-rule add --catalog "${RR_CATALOG}" --name "${RR_NAME}" -f 
"${SEED_MAL}" >/dev/null \
     || fail "addOrUpdate ${RR_CATALOG}/${RR_NAME} failed"
 deadline=$(( $(date +%s) + 60 ))
 status=""
 while (( $(date +%s) < deadline )); do
-    status="$(curl -fsS "${OAP_BASE}/runtime/rule/list" 2>/dev/null \
+    status="$(admin runtime-rule list 2>/dev/null \
         | jq -r --arg c "${RR_CATALOG}" --arg n "${RR_NAME}" \
             '.rules[] | select(.catalog == $c and .name == $n) | .status' | 
head -1)"
     [ "${status}" = "ACTIVE" ] && break
@@ -83,8 +86,8 @@ log "✓ seed applied: ${RR_CATALOG}/${RR_NAME} ACTIVE"
 
 # --- Phase 2: install session 
---------------------------------------------------------
 log "=== Phase 2: install session on (${DBG_CATALOG}, ${DBG_NAME}, 
${DBG_RULE_NAME}) ==="
-install_body="$(curl -fsS -XPOST \
-    
"${OAP_BASE}/dsl-debugging/session?catalog=${DBG_CATALOG}&name=${DBG_NAME}&ruleName=${DBG_RULE_NAME}&clientId=${CLIENT_ID}")"
+install_body="$(admin dsl-debug session start \
+    --catalog "${DBG_CATALOG}" --name "${DBG_NAME}" --rule-name 
"${DBG_RULE_NAME}" --client-id "${CLIENT_ID}")"
 log "  install → ${install_body}"
 SESSION_ID="$(echo "${install_body}" | jq -r '.sessionId // empty')"
 [ -n "${SESSION_ID}" ] || fail "install did not return sessionId — body: 
${install_body}"
@@ -96,7 +99,7 @@ deadline=$(( $(date +%s) + SETTLE_SECONDS ))
 records_count=0
 collect_body=""
 while (( $(date +%s) < deadline )); do
-    collect_body="$(curl -fsS 
"${OAP_BASE}/dsl-debugging/session/${SESSION_ID}")"
+    collect_body="$(admin dsl-debug session get "${SESSION_ID}")"
     records_count="$(echo "${collect_body}" | jq '[.nodes[].records[]] | 
length')"
     if [ "${records_count}" -gt 0 ]; then
         # Wait for at least one full execution (terminal meterEmit closed it),
@@ -218,13 +221,13 @@ log "✓ MAL shape valid (${records_count} records, 
${total_samples} samples)"
 
 # --- Phase 6: stop session 
------------------------------------------------------------
 log "=== Phase 6: stop session ==="
-curl -fsS -XPOST "${OAP_BASE}/dsl-debugging/session/${SESSION_ID}/stop" \
+admin dsl-debug session stop "${SESSION_ID}" \
     | jq -e '.localStopped == true' >/dev/null \
     || fail "localStopped != true"
 
 # --- Phase 7: cleanup runtime-rule 
----------------------------------------------------
 log "=== Phase 7: cleanup runtime-rule ==="
-curl -fsS -XPOST 
"${OAP_BASE}/runtime/rule/inactivate?catalog=${RR_CATALOG}&name=${RR_NAME}" 
>/dev/null || true
-curl -fsS -XPOST 
"${OAP_BASE}/runtime/rule/delete?catalog=${RR_CATALOG}&name=${RR_NAME}" 
>/dev/null || true
+admin runtime-rule inactivate --catalog "${RR_CATALOG}" --name "${RR_NAME}" 
>/dev/null || true
+admin runtime-rule delete --catalog "${RR_CATALOG}" --name "${RR_NAME}" 
>/dev/null || true
 
 log "=== ALL MAL FLOW PHASES PASSED ==="
diff --git a/test/e2e-v2/cases/dsl-debugging/mal/e2e.yaml 
b/test/e2e-v2/cases/dsl-debugging/mal/e2e.yaml
index c0fe353257..d2c9dec2d4 100644
--- a/test/e2e-v2/cases/dsl-debugging/mal/e2e.yaml
+++ b/test/e2e-v2/cases/dsl-debugging/mal/e2e.yaml
@@ -50,7 +50,7 @@ verify:
     count: 1
     interval: 1s
   cases:
-    - query: curl -fsS http://127.0.0.1:17128/dsl-debugging/status >/dev/null 
&& echo ok
+    - query: swctl --display json --admin-url=http://127.0.0.1:17128 admin 
dsl-debug status >/dev/null && echo ok
       expected: expected/ok.txt
 
 cleanup:
diff --git a/test/e2e-v2/cases/dsl-debugging/oal/dsl-debug-flow.sh 
b/test/e2e-v2/cases/dsl-debugging/oal/dsl-debug-flow.sh
index 80027e186c..240b3c99f3 100755
--- a/test/e2e-v2/cases/dsl-debugging/oal/dsl-debug-flow.sh
+++ b/test/e2e-v2/cases/dsl-debugging/oal/dsl-debug-flow.sh
@@ -38,6 +38,11 @@ OAP_HOST="${OAP_HOST:-127.0.0.1}"
 OAP_REST_PORT="${OAP_REST_PORT:-17128}"
 OAP_BASE="http://${OAP_HOST}:${OAP_REST_PORT}";
 
+# All admin-server REST calls go through swctl's `admin` command tree instead 
of
+# raw curl. `--display json` keeps the body shape identical to the old curl
+# output, so the downstream jq assertions are unchanged.
+admin() { swctl --display json --admin-url="${OAP_BASE}" admin "$@"; }
+
 SETTLE_SECONDS="${SETTLE_SECONDS:-300}"
 
 # OAL gate is per-metric. We target service_relation_server_cpm — a shipped
@@ -51,20 +56,20 @@ CLIENT_ID="e2e-dsldbg-oal-1"
 
 log "waiting for OAP admin port"
 deadline=$(( $(date +%s) + 120 ))
-until curl -fsS "${OAP_BASE}/dsl-debugging/status" >/dev/null 2>&1; do
+until admin dsl-debug status >/dev/null 2>&1; do
     if [ "$(date +%s)" -ge "${deadline}" ]; then fail "OAP admin not ready 
after 120s"; fi
     sleep 2
 done
 
 # --- Phase 0: status 
------------------------------------------------------------------
-log "=== Phase 0: /dsl-debugging/status ==="
-curl -fsS "${OAP_BASE}/dsl-debugging/status" | jq -e '.injectionEnabled == 
true' >/dev/null \
+log "=== Phase 0: dsl-debug status ==="
+admin dsl-debug status | jq -e '.injectionEnabled == true' >/dev/null \
     || fail "injectionEnabled is not true"
 
 # --- Phase 1: install session 
---------------------------------------------------------
 log "=== Phase 1: install session on (${CATALOG}, ${NAME}, ${METRIC}) ==="
-install_body="$(curl -fsS -XPOST \
-    
"${OAP_BASE}/dsl-debugging/session?catalog=${CATALOG}&name=${NAME}&ruleName=${METRIC}&clientId=${CLIENT_ID}")"
+install_body="$(admin dsl-debug session start \
+    --catalog "${CATALOG}" --name "${NAME}" --rule-name "${METRIC}" 
--client-id "${CLIENT_ID}")"
 log "  install → ${install_body}"
 SESSION_ID="$(echo "${install_body}" | jq -r '.sessionId // empty')"
 [ -n "${SESSION_ID}" ] || fail "install did not return sessionId — body: 
${install_body}"
@@ -76,7 +81,7 @@ deadline=$(( $(date +%s) + SETTLE_SECONDS ))
 records_count=0
 collect_body=""
 while (( $(date +%s) < deadline )); do
-    collect_body="$(curl -fsS 
"${OAP_BASE}/dsl-debugging/session/${SESSION_ID}")"
+    collect_body="$(admin dsl-debug session get "${SESSION_ID}")"
     records_count="$(echo "${collect_body}" | jq '[.nodes[].records[]] | 
length')"
     if [ "${records_count}" -gt 0 ]; then
         # Wait for at least one execution record with both source + filter 
samples.
@@ -197,7 +202,7 @@ log "✓ OAL shape valid (${records_count} records, 
${total_samples} samples, so
 
 # --- Phase 5: stop session 
------------------------------------------------------------
 log "=== Phase 5: stop session ==="
-curl -fsS -XPOST "${OAP_BASE}/dsl-debugging/session/${SESSION_ID}/stop" \
+admin dsl-debug session stop "${SESSION_ID}" \
     | jq -e '.localStopped == true' >/dev/null \
     || fail "localStopped != true"
 
diff --git a/test/e2e-v2/cases/runtime-rule/cluster/cluster-flow.sh 
b/test/e2e-v2/cases/runtime-rule/cluster/cluster-flow.sh
index bf2e7cf0e9..ac371559cb 100755
--- a/test/e2e-v2/cases/runtime-rule/cluster/cluster-flow.sh
+++ b/test/e2e-v2/cases/runtime-rule/cluster/cluster-flow.sh
@@ -49,9 +49,15 @@ CONVERGE_TIMEOUT_S="${CONVERGE_TIMEOUT_S:-90}"
 
 [ -f "${SEED_NEW}" ] || fail "seed-rule.yaml missing at ${SEED_NEW}"
 
+# All runtime-rule REST calls go through swctl's `admin` command tree instead 
of
+# raw curl. This flow drives two OAP nodes, so the admin host (`--admin-url`) 
is
+# passed per call as the first argument. `--display json` keeps the body shape
+# identical to the old curl output, so the jq assertions are unchanged.
+admin() { local base="$1"; shift; swctl --display json --admin-url="${base}" 
admin "$@"; }
+
 list_row() {
     local base="$1"
-    curl -fsS "${base}/runtime/rule/list" 2>/dev/null \
+    admin "${base}" runtime-rule list 2>/dev/null \
         | jq -c '.rules[]
                  | select(.catalog == "'"${CATALOG}"'" and .name == 
"'"${NAME}"'")
                  | select(.status != "n/a")' \
@@ -113,12 +119,9 @@ await_absent() {
 
 apply_on() {
     local base="$1" body="$2" extra="${3:-}"
-    local query="catalog=${CATALOG}&name=${NAME}"
-    if [ -n "${extra}" ]; then
-        query="${query}&${extra}"
-    fi
-    local resp; resp="$(curl -fsS -XPOST -H 'Content-Type: text/plain' \
-        --data-binary "@${body}" "${base}/runtime/rule/addOrUpdate?${query}")" 
\
+    local -a flags=(--catalog "${CATALOG}" --name "${NAME}" -f "${body}")
+    [[ "${extra}" == *allowStorageChange=true* ]] && 
flags+=(--allow-storage-change)
+    local resp; resp="$(admin "${base}" runtime-rule add "${flags[@]}")" \
         || fail "addOrUpdate against ${base} failed"
     echo "${resp}"
 }
@@ -126,13 +129,13 @@ apply_on() {
 # --- Wait for both OAPs to come up 
-------------------------------------------------
 log "waiting for OAP-1 (${OAP1_BASE})"
 deadline=$(( $(date +%s) + 120 ))
-until curl -fsS "${OAP1_BASE}/runtime/rule/list" >/dev/null 2>&1; do
+until admin "${OAP1_BASE}" runtime-rule list >/dev/null 2>&1; do
     if [ "$(date +%s)" -ge "${deadline}" ]; then fail "OAP-1 not ready after 
120s"; fi
     sleep 2
 done
 log "waiting for OAP-2 (${OAP2_BASE})"
 deadline=$(( $(date +%s) + 120 ))
-until curl -fsS "${OAP2_BASE}/runtime/rule/list" >/dev/null 2>&1; do
+until admin "${OAP2_BASE}" runtime-rule list >/dev/null 2>&1; do
     if [ "$(date +%s)" -ge "${deadline}" ]; then fail "OAP-2 not ready after 
120s"; fi
     sleep 2
 done
@@ -159,7 +162,7 @@ log "OAP-2 converged to ${hash_struct:0:8}…"
 
 # --- Phase 3: inactivate on OAP-1, observe INACTIVE on OAP-2 
-----------------------
 log "=== Phase 3: /inactivate on OAP-1 ==="
-curl -fsS -XPOST 
"${OAP1_BASE}/runtime/rule/inactivate?catalog=${CATALOG}&name=${NAME}" 
>/dev/null \
+admin "${OAP1_BASE}" runtime-rule inactivate --catalog "${CATALOG}" --name 
"${NAME}" >/dev/null \
     || fail "inactivate against OAP-1 failed"
 await_status "${OAP1_BASE}" "INACTIVE"
 log "OAP-1 → INACTIVE"
@@ -168,7 +171,7 @@ log "OAP-2 converged to INACTIVE"
 
 # --- Phase 4: delete on OAP-1, observe row gone on OAP-2 
---------------------------
 log "=== Phase 4: /delete on OAP-1 ==="
-curl -fsS -XPOST 
"${OAP1_BASE}/runtime/rule/delete?catalog=${CATALOG}&name=${NAME}" >/dev/null \
+admin "${OAP1_BASE}" runtime-rule delete --catalog "${CATALOG}" --name 
"${NAME}" >/dev/null \
     || fail "delete against OAP-1 failed"
 await_absent "${OAP1_BASE}"
 log "OAP-1 → row gone"
diff --git a/test/e2e-v2/cases/runtime-rule/cluster/e2e.yaml 
b/test/e2e-v2/cases/runtime-rule/cluster/e2e.yaml
index 477a8a52b3..d6b82f0c8d 100644
--- a/test/e2e-v2/cases/runtime-rule/cluster/e2e.yaml
+++ b/test/e2e-v2/cases/runtime-rule/cluster/e2e.yaml
@@ -31,6 +31,8 @@ setup:
             
https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64
           chmod +x /tmp/skywalking-infra-e2e/bin/jq
         fi
+    - name: install swctl
+      command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl
     - name: drive cluster convergence flow
       command: |
         set -euo pipefail
@@ -42,7 +44,7 @@ verify:
     count: 1
     interval: 1s
   cases:
-    - query: curl -fsS http://127.0.0.1:17128/runtime/rule/list >/dev/null && 
echo ok
+    - query: swctl --display json --admin-url=http://127.0.0.1:17128 admin 
runtime-rule list >/dev/null && echo ok
       expected: expected/ok.txt
 
 cleanup:
diff --git a/test/e2e-v2/cases/runtime-rule/lal/e2e.yaml 
b/test/e2e-v2/cases/runtime-rule/lal/e2e.yaml
index 3cc008a429..b02b7c2aa0 100644
--- a/test/e2e-v2/cases/runtime-rule/lal/e2e.yaml
+++ b/test/e2e-v2/cases/runtime-rule/lal/e2e.yaml
@@ -52,7 +52,7 @@ verify:
     count: 1
     interval: 1s
   cases:
-    - query: curl -fsS http://127.0.0.1:17128/runtime/rule/list >/dev/null && 
echo ok
+    - query: swctl --display json --admin-url=http://127.0.0.1:17128 admin 
runtime-rule list >/dev/null && echo ok
       expected: expected/ok.txt
 
 cleanup:
diff --git a/test/e2e-v2/cases/runtime-rule/lal/lal-flow.sh 
b/test/e2e-v2/cases/runtime-rule/lal/lal-flow.sh
index 5c3a905869..aa57fd58bd 100755
--- a/test/e2e-v2/cases/runtime-rule/lal/lal-flow.sh
+++ b/test/e2e-v2/cases/runtime-rule/lal/lal-flow.sh
@@ -58,9 +58,14 @@ SETTLE_SECONDS="${SETTLE_SECONDS:-360}"
 [ -f "${SEED_V2}" ]  || fail "seed v2 missing at ${SEED_V2}"
 [ -f "${SEED_MAL}" ] || fail "seed mal missing at ${SEED_MAL}"
 
+# All runtime-rule REST calls go through swctl's `admin` command tree instead 
of
+# raw curl. `--display json` keeps the response body shape identical to the old
+# curl output, so the jq assertions below are unchanged.
+admin() { swctl --display json --admin-url="${OAP_BASE}" admin "$@"; }
+
 list_row() {
     local catalog="$1" name="$2"
-    curl -fsS "${OAP_BASE}/runtime/rule/list" 2>/dev/null \
+    admin runtime-rule list 2>/dev/null \
         | jq -c '.rules[]
                  | select(.catalog == "'"${catalog}"'" and .name == 
"'"${name}"'")
                  | select(.status != "n/a")' \
@@ -73,22 +78,20 @@ list_field() {
 
 apply_rule() {
     local catalog="$1" name="$2" body="$3"
-    curl -fsS -XPOST -H 'Content-Type: text/plain' \
-        --data-binary "@${body}" \
-        "${OAP_BASE}/runtime/rule/addOrUpdate?catalog=${catalog}&name=${name}" 
>/dev/null \
+    admin runtime-rule add --catalog "${catalog}" --name "${name}" -f 
"${body}" >/dev/null \
         || fail "addOrUpdate ${catalog}/${name} from ${body} failed"
 }
 
 # Retries 503 cluster_not_ready for up to 60s — the reconciler's peer-refresh
 # window briefly returns 503 right after a structural reshape (e.g. LAL
-# delete that retires its dispatcher). Mirrors the MAL flow's pattern.
-retry_post() {
-    local url="$1"
+# delete that retires its dispatcher). Mirrors the MAL flow's pattern. Pass the
+# runtime-rule subcommand and its flags.
+retry_admin() {
     local deadline=$(( $(date +%s) + 60 ))
     local out
     while (( $(date +%s) < deadline )); do
-        out="$(curl -fsS -XPOST "${url}" 2>&1)" && return 0
-        if [[ "${out}" == *503* ]]; then
+        out="$(admin "$@" 2>&1)" && return 0
+        if echo "${out}" | grep -q "HTTP 503"; then
             sleep 2
             continue
         fi
@@ -101,13 +104,13 @@ retry_post() {
 
 inactivate_rule() {
     local catalog="$1" name="$2"
-    retry_post 
"${OAP_BASE}/runtime/rule/inactivate?catalog=${catalog}&name=${name}" 
>/dev/null \
+    retry_admin runtime-rule inactivate --catalog "${catalog}" --name 
"${name}" >/dev/null \
         || fail "inactivate ${catalog}/${name} failed"
 }
 
 delete_rule() {
     local catalog="$1" name="$2"
-    retry_post 
"${OAP_BASE}/runtime/rule/delete?catalog=${catalog}&name=${name}" >/dev/null \
+    retry_admin runtime-rule delete --catalog "${catalog}" --name "${name}" 
>/dev/null \
         || fail "delete ${catalog}/${name} failed"
 }
 
@@ -143,7 +146,7 @@ await_metric_for_step() {
 
 log "waiting for OAP runtime-rule port"
 deadline=$(( $(date +%s) + 90 ))
-until curl -fsS "${OAP_BASE}/runtime/rule/list" >/dev/null 2>&1; do
+until admin runtime-rule list >/dev/null 2>&1; do
     if [ "$(date +%s)" -ge "${deadline}" ]; then fail "OAP not ready after 
90s"; fi
     sleep 2
 done
diff --git a/test/e2e-v2/cases/runtime-rule/mal-storage/banyandb/e2e.yaml 
b/test/e2e-v2/cases/runtime-rule/mal-storage/banyandb/e2e.yaml
index fb8033a540..9d09a5fc14 100644
--- a/test/e2e-v2/cases/runtime-rule/mal-storage/banyandb/e2e.yaml
+++ b/test/e2e-v2/cases/runtime-rule/mal-storage/banyandb/e2e.yaml
@@ -65,7 +65,7 @@ verify:
     # minimal so the harness reports the script's pass/fail directly. A 
trailing
     # /list smoke check confirms the receiver port is still serving after the
     # destructive phase (catches regressions where /delete crashes the 
handler).
-    - query: curl -fsS http://127.0.0.1:17128/runtime/rule/list >/dev/null && 
echo ok
+    - query: swctl --display json --admin-url=http://127.0.0.1:17128 admin 
runtime-rule list >/dev/null && echo ok
       expected: expected/ok.txt
 
 cleanup:
diff --git a/test/e2e-v2/cases/runtime-rule/mal-storage/elasticsearch/e2e.yaml 
b/test/e2e-v2/cases/runtime-rule/mal-storage/elasticsearch/e2e.yaml
index 260773f1b2..d449ed83aa 100644
--- a/test/e2e-v2/cases/runtime-rule/mal-storage/elasticsearch/e2e.yaml
+++ b/test/e2e-v2/cases/runtime-rule/mal-storage/elasticsearch/e2e.yaml
@@ -56,7 +56,7 @@ verify:
     count: 1
     interval: 1s
   cases:
-    - query: curl -fsS http://127.0.0.1:17128/runtime/rule/list >/dev/null && 
echo ok
+    - query: swctl --display json --admin-url=http://127.0.0.1:17128 admin 
runtime-rule list >/dev/null && echo ok
       expected: ../expected/ok.txt
 
 cleanup:
diff --git a/test/e2e-v2/cases/runtime-rule/mal-storage/postgresql/e2e.yaml 
b/test/e2e-v2/cases/runtime-rule/mal-storage/postgresql/e2e.yaml
index f15d0afd19..6562aae4ec 100644
--- a/test/e2e-v2/cases/runtime-rule/mal-storage/postgresql/e2e.yaml
+++ b/test/e2e-v2/cases/runtime-rule/mal-storage/postgresql/e2e.yaml
@@ -57,7 +57,7 @@ verify:
     count: 1
     interval: 1s
   cases:
-    - query: curl -fsS http://127.0.0.1:17128/runtime/rule/list >/dev/null && 
echo ok
+    - query: swctl --display json --admin-url=http://127.0.0.1:17128 admin 
runtime-rule list >/dev/null && echo ok
       expected: ../expected/ok.txt
 
 cleanup:
diff --git a/test/e2e-v2/cases/runtime-rule/mal-storage/runtime-rule-flow.sh 
b/test/e2e-v2/cases/runtime-rule/mal-storage/runtime-rule-flow.sh
index 97831551aa..d9832ec10a 100755
--- a/test/e2e-v2/cases/runtime-rule/mal-storage/runtime-rule-flow.sh
+++ b/test/e2e-v2/cases/runtime-rule/mal-storage/runtime-rule-flow.sh
@@ -64,6 +64,14 @@ GQL_BASE="http://${OAP_HOST}:${OAP_GQL_PORT}";
 log()   { echo "[runtime-rule-flow] $*" >&2; }
 fail()  { echo "[runtime-rule-flow] FAIL: $*" >&2; exit 1; }
 
+# Every runtime-rule REST call goes through swctl's `admin` command tree 
instead
+# of raw curl. `--display json` keeps the response body byte-shape identical to
+# the old curl output (the runtime-rule endpoints are passed through verbatim),
+# so the jq assertions below are unchanged. On a non-2xx the CLI exits non-zero
+# and renders the typed error envelope — `admin API <url>: HTTP <code>
+# (<applyStatus>): <message>` — which the negative-path helpers grep for.
+admin() { swctl --display json --admin-url="${REST_BASE}" admin "$@"; }
+
 # Resolve the otlp-emitter container by name fragment so we don't need to know
 # the compose project name. Cached on first lookup.
 EMITTER_CONTAINER=""
@@ -89,101 +97,103 @@ step_set() {
   log "  step=${value}"
 }
 
-# Retry a 2xx-or-fail curl for up to RETRY_BUDGET_S seconds. Exists because the
-# cluster routing layer transiently returns 503 cluster_not_ready when its peer
+# Retry a runtime-rule admin call for up to RETRY_BUDGET_S seconds. Exists 
because
+# the cluster routing layer transiently returns 503 cluster_not_ready when its 
peer
 # refresh is in flight; happens reliably right after a STRUCTURAL apply (the
 # reconciler's cache may be paused). Operator retries after a few seconds work
-# in practice, so the e2e applies the same pattern automatically.
+# in practice, so the e2e applies the same pattern automatically. Pass the
+# runtime-rule subcommand and its flags, e.g.
+#   retry_admin runtime-rule inactivate --catalog "${CATALOG}" --name "${NAME}"
 RETRY_BUDGET_S="${RETRY_BUDGET_S:-60}"
-retry_curl_post() {
-  local url="$1"
-  local body_arg="${2:-}"   # e.g. --data-binary @file ; empty for empty-body 
POST
+retry_admin() {
   local deadline=$(( $(date +%s) + RETRY_BUDGET_S ))
-  local out
+  local out rc
   while (( $(date +%s) < deadline )); do
-    if [[ -n "${body_arg}" ]]; then
-      # shellcheck disable=SC2086
-      out="$(curl -fsS -XPOST ${body_arg} -H "Content-Type: text/plain" 
"${url}" 2>&1)" && {
-        echo "${out}"; return 0;
-      }
-    else
-      out="$(curl -fsS -XPOST "${url}" 2>&1)" && { echo "${out}"; return 0; }
-    fi
-    if [[ "${out}" == *503* ]]; then
-      log "  transient 503 on ${url} — retrying"
+    out="$(admin "$@" 2>&1)" && { echo "${out}"; return 0; }
+    rc=$?
+    if echo "${out}" | grep -q "HTTP 503"; then
+      log "  transient 503 on 'admin $*' — retrying"
       sleep 2
       continue
     fi
     echo "${out}"
-    return 1
+    return "${rc}"
   done
   echo "${out}"
   return 1
 }
 
-# POST a rule file to /addOrUpdate. Echoes the JSON response. Asserts 200.
+# Apply a rule file via addOrUpdate. Echoes the JSON response. Asserts 2xx.
+# extra="allowStorageChange=true" maps to the --allow-storage-change flag.
 post_rule() {
   local file="$1"
-  local extra_qs="${2:-}"
+  local extra="${2:-}"
   local rule_name="${3:-${NAME}}"
-  local 
url="${REST_BASE}/runtime/rule/addOrUpdate?catalog=${CATALOG}&name=${rule_name}${extra_qs:+&${extra_qs}}"
-  log "POST ${url} (body=${file})"
+  local -a flags=(--catalog "${CATALOG}" --name "${rule_name}" -f "${file}")
+  [[ "${extra}" == *allowStorageChange=true* ]] && 
flags+=(--allow-storage-change)
+  log "runtime-rule add ${CATALOG}/${rule_name} (body=${file})"
   local resp
-  resp="$(curl -fsS -XPOST --data-binary "@${file}" -H "Content-Type: 
text/plain" "${url}")" \
+  resp="$(admin runtime-rule add "${flags[@]}")" \
     || fail "addOrUpdate of ${file} returned non-2xx"
   log "  → ${resp}"
   echo "${resp}"
 }
 
-# POST a rule that's expected to be REJECTED. Captures the HTTP status and the
-# response body via curl's separate -w / -o, asserts the status matches, and
-# echoes the body so callers can grep for a specific failure code/string.
+# Apply a rule that's expected to be REJECTED. swctl exits non-zero on a 
non-2xx
+# and renders the typed error envelope ("... HTTP <code> (<applyStatus>): 
<msg>")
+# to stdout; assert the HTTP code is present and echo the message so callers 
can
+# grep for a specific failure code / applyStatus / string.
 post_rule_expect_status() {
   local file="$1"
   local expected_status="$2"
-  local extra_qs="${3:-}"
+  local extra="${3:-}"
   local rule_name="${4:-${NAME}}"
-  local 
url="${REST_BASE}/runtime/rule/addOrUpdate?catalog=${CATALOG}&name=${rule_name}${extra_qs:+&${extra_qs}}"
-  log "POST ${url} (expect HTTP ${expected_status}, body=${file})"
-  local body_file http_status
-  body_file="$(mktemp)"
-  http_status="$(curl -sS -o "${body_file}" -w '%{http_code}' \
-    -XPOST --data-binary "@${file}" -H "Content-Type: text/plain" "${url}")"
-  local body
-  body="$(cat "${body_file}")"
-  rm -f "${body_file}"
-  log "  ← HTTP ${http_status} body=${body}"
-  [[ "${http_status}" == "${expected_status}" ]] \
-    || fail "expected HTTP ${expected_status}, got ${http_status} (body: 
${body})"
-  echo "${body}"
+  local -a flags=(--catalog "${CATALOG}" --name "${rule_name}" -f "${file}")
+  [[ "${extra}" == *allowStorageChange=true* ]] && 
flags+=(--allow-storage-change)
+  log "runtime-rule add ${CATALOG}/${rule_name} (expect HTTP 
${expected_status}, body=${file})"
+  local out rc
+  out="$(admin runtime-rule add "${flags[@]}" 2>&1)" && rc=0 || rc=$?
+  log "  ← rc=${rc} ${out}"
+  [[ "${rc}" -ne 0 ]] \
+    || fail "expected rejection (HTTP ${expected_status}) but add succeeded: 
${out}"
+  echo "${out}" | grep -q "HTTP ${expected_status}" \
+    || fail "expected HTTP ${expected_status}, got: ${out}"
+  echo "${out}"
 }
 
-# POST a non-/addOrUpdate endpoint that's expected to be REJECTED. Same
-# semantics as post_rule_expect_status but takes an explicit URL.
-post_url_expect_status() {
-  local url="$1"
+# Delete a rule that's expected to be REJECTED (e.g. /delete on an ACTIVE row →
+# 409 requires_inactivate_first). Same envelope-grep semantics as
+# post_rule_expect_status.
+delete_expect_status() {
+  local rule_name="$1"
   local expected_status="$2"
-  log "POST ${url} (expect HTTP ${expected_status})"
-  local body_file http_status
-  body_file="$(mktemp)"
-  http_status="$(curl -sS -o "${body_file}" -w '%{http_code}' -XPOST "${url}")"
-  local body
-  body="$(cat "${body_file}")"
-  rm -f "${body_file}"
-  log "  ← HTTP ${http_status} body=${body}"
-  [[ "${http_status}" == "${expected_status}" ]] \
-    || fail "expected HTTP ${expected_status}, got ${http_status} (body: 
${body})"
-  echo "${body}"
+  log "runtime-rule delete ${CATALOG}/${rule_name} (expect HTTP 
${expected_status})"
+  local out rc
+  out="$(admin runtime-rule delete --catalog "${CATALOG}" --name 
"${rule_name}" 2>&1)" && rc=0 || rc=$?
+  log "  ← rc=${rc} ${out}"
+  [[ "${rc}" -ne 0 ]] \
+    || fail "expected delete rejection (HTTP ${expected_status}) but it 
succeeded: ${out}"
+  echo "${out}" | grep -q "HTTP ${expected_status}" \
+    || fail "expected HTTP ${expected_status}, got: ${out}"
+  echo "${out}"
 }
 
-# Assert the JSON response carries the expected applyStatus.
+# Assert the expected applyStatus. On the happy path the argument is the JSON
+# ApplyResult and the status comes from .applyStatus. On a rejection the 
argument
+# is swctl's error line, where the CLI's typed envelope renders the 
applyStatus in
+# parentheses, e.g. "... HTTP 400 (layer_ordinal_out_of_range): <msg>".
 assert_apply_status() {
   local expected="$1"
-  local actual_json="$2"
-  local actual
-  actual="$(echo "${actual_json}" | jq -r '.applyStatus // empty')"
-  [[ "${actual}" == "${expected}" ]] \
-    || fail "expected applyStatus=${expected}, got '${actual}' (full: 
${actual_json})"
+  local actual="$2"
+  local parsed
+  parsed="$(echo "${actual}" | jq -r '.applyStatus // empty' 2>/dev/null || 
true)"
+  if [[ -n "${parsed}" ]]; then
+    [[ "${parsed}" == "${expected}" ]] \
+      || fail "expected applyStatus=${expected}, got '${parsed}' (full: 
${actual})"
+    return 0
+  fi
+  echo "${actual}" | grep -q "(${expected})" \
+    || fail "expected applyStatus=${expected}, not found in: ${actual}"
 }
 
 # GET /runtime/rule/list and ensure the row matches the expected status. 
Returns
@@ -191,10 +201,10 @@ assert_apply_status() {
 list_row() {
   local expected_status="$1"
   local rule_name="${2:-${NAME}}"
-  log "GET /runtime/rule/list → looking for ${CATALOG}/${rule_name} 
status=${expected_status}"
+  log "runtime-rule list → looking for ${CATALOG}/${rule_name} 
status=${expected_status}"
   local lines
-  lines="$(curl -fsS "${REST_BASE}/runtime/rule/list")" \
-    || fail "GET /runtime/rule/list failed"
+  lines="$(admin runtime-rule list)" \
+    || fail "runtime-rule list failed"
   local match
   match="$(echo "${lines}" | jq -c ".rules[] | select(.catalog==\"${CATALOG}\" 
and .name==\"${rule_name}\")" 2>/dev/null || true)"
   [[ -n "${match}" ]] \
@@ -209,10 +219,10 @@ list_row() {
 # Assert that /list does NOT have a row for the given (catalog, name).
 list_no_row() {
   local rule_name="${1:-${NAME}}"
-  log "GET /runtime/rule/list → expect NO row for ${CATALOG}/${rule_name}"
+  log "runtime-rule list → expect NO row for ${CATALOG}/${rule_name}"
   local lines match
-  lines="$(curl -fsS "${REST_BASE}/runtime/rule/list")" \
-    || fail "GET /runtime/rule/list failed"
+  lines="$(admin runtime-rule list)" \
+    || fail "runtime-rule list failed"
   match="$(echo "${lines}" | jq -c ".rules[] | select(.catalog==\"${CATALOG}\" 
and .name==\"${rule_name}\")" 2>/dev/null || true)"
   if [[ -n "${match}" ]]; then
     local status
@@ -404,8 +414,8 @@ assert_dump_contains() {
   shift
   local tar_file
   tar_file="$(mktemp)"
-  curl -fsS "${REST_BASE}/runtime/rule/dump" -o "${tar_file}" \
-    || fail "GET /runtime/rule/dump failed (${label})"
+  admin runtime-rule dump -o "${tar_file}" >/dev/null \
+    || fail "runtime-rule dump failed (${label})"
   local entries
   entries="$(tar -tzf "${tar_file}" 2>&1)" \
     || { rm -f "${tar_file}"; fail "${label}: dump body is not a valid tar.gz: 
${entries}"; }
@@ -422,7 +432,7 @@ assert_dump_contains() {
 
 log "waiting for OAP runtime-rule port ${OAP_REST_PORT}"
 for _ in $(seq 1 60); do
-  curl -fsS "${REST_BASE}/runtime/rule/list" >/dev/null 2>&1 && break
+  admin runtime-rule list >/dev/null 2>&1 && break
   sleep 2
 done
 
@@ -495,7 +505,7 @@ assert_metric_step_advanced "e2e_rr_requests" "structural" 
"${struct_baseline}"
 
 log "=== Phase 5c: ILLEGAL /delete on ACTIVE row ==="
 struct_baseline="$(latest_bucket_id_for_step "e2e_rr_requests" "structural")"
-post_url_expect_status 
"${REST_BASE}/runtime/rule/delete?catalog=${CATALOG}&name=${NAME}" "409" 
>/dev/null
+delete_expect_status "${NAME}" "409" >/dev/null
 [[ "$(list_row ACTIVE | jq -r '.contentHash')" == "${hash_structural}" ]] \
   || fail "5c: row state changed after /delete-on-ACTIVE rejection"
 assert_metric_step_advanced "e2e_rr_requests" "structural" 
"${struct_baseline}" 180
@@ -540,7 +550,9 @@ resp="$(post_rule_expect_status \
   "${SEED_RULES_DIR}/illegal-layer-name-conflict.yaml" "400" "" 
"${SIBLING_NAME}")"
 assert_apply_status "layer_name_conflict" "${resp}"
 # Message must name the conflicting source so operators see what to align with.
-echo "${resp}" | jq -e '.message | test("built-in")' >/dev/null \
+# resp is swctl's plain-text error envelope ("... (layer_name_conflict): 
<msg>"),
+# not JSON, so grep the message directly rather than parsing it.
+echo "${resp}" | grep -q "built-in" \
   || fail "5g: response message did not label source as built-in: ${resp}"
 list_no_row "${SIBLING_NAME}"
 [[ "$(list_row ACTIVE | jq -r '.contentHash')" == "${hash_structural}" ]] \
@@ -576,13 +588,13 @@ oap_container="$(docker ps --filter 
"ancestor=skywalking/oap:latest" \
 docker restart "${oap_container}" >/dev/null
 # Wait for the REST port to come back. Cap at 180s so a true hang surfaces.
 for i in $(seq 1 90); do
-  if curl -fsS "${REST_BASE}/runtime/rule/list" >/dev/null 2>&1; then
+  if admin runtime-rule list >/dev/null 2>&1; then
     log "  OAP back up after ${i}*2s"
     break
   fi
   sleep 2
 done
-curl -fsS "${REST_BASE}/runtime/rule/list" >/dev/null \
+admin runtime-rule list >/dev/null \
   || fail "5h: OAP did not come back online after restart"
 
 # Critical assertion: the runtime layer must still be visible AND retain its
@@ -599,9 +611,9 @@ list_row "ACTIVE" "${SIBLING_NAME}" >/dev/null
 log "  post-restart: layer + rule survived"
 
 # Now prove the layer is still removable through the dynamic channel.
-retry_curl_post 
"${REST_BASE}/runtime/rule/inactivate?catalog=${CATALOG}&name=${SIBLING_NAME}" 
>/dev/null \
+retry_admin runtime-rule inactivate --catalog "${CATALOG}" --name 
"${SIBLING_NAME}" >/dev/null \
   || fail "5h: sibling inactivate failed"
-retry_curl_post 
"${REST_BASE}/runtime/rule/delete?catalog=${CATALOG}&name=${SIBLING_NAME}" 
>/dev/null \
+retry_admin runtime-rule delete --catalog "${CATALOG}" --name 
"${SIBLING_NAME}" >/dev/null \
   || fail "5h: sibling delete failed"
 list_no_row "${SIBLING_NAME}"
 sleep 2
@@ -619,14 +631,12 @@ assert_metric_step_advanced "e2e_rr_requests" 
"structural" "${struct_baseline}"
 # POST a new shape under the same (catalog, name).
 log "=== Phase 6: SHAPE-BREAK ==="
 step_set "shape_break_old"
-log "  /inactivate to release the old shape"
-inactivate_url="${REST_BASE}/runtime/rule/inactivate?catalog=${CATALOG}&name=${NAME}"
-retry_curl_post "${inactivate_url}" >/dev/null \
+log "  inactivate to release the old shape"
+retry_admin runtime-rule inactivate --catalog "${CATALOG}" --name "${NAME}" 
>/dev/null \
   || fail "shape-break: inactivate failed"
 list_row "INACTIVE" >/dev/null
-log "  /delete to drop the old measure"
-delete_url="${REST_BASE}/runtime/rule/delete?catalog=${CATALOG}&name=${NAME}"
-retry_curl_post "${delete_url}" >/dev/null \
+log "  delete to drop the old measure"
+retry_admin runtime-rule delete --catalog "${CATALOG}" --name "${NAME}" 
>/dev/null \
   || fail "shape-break: delete failed"
 list_no_row
 
@@ -647,7 +657,7 @@ await_metric_for_step "e2e_rr_requests" "shape_break_new"
 # window where the rule is still active aggregates a few `step=inactivate`
 # samples and the soft-pause assertion below fails for the wrong reason.
 log "=== Phase 7: INACTIVATE (soft-pause) ==="
-retry_curl_post "${inactivate_url}" >/dev/null \
+retry_admin runtime-rule inactivate --catalog "${CATALOG}" --name "${NAME}" 
>/dev/null \
   || fail "phase-7: inactivate failed"
 list_row "INACTIVE" >/dev/null
 step_set "inactivate"
@@ -673,10 +683,10 @@ await_metric_for_step "e2e_rr_requests" "activate"
 # Phase 9 — DELETE (destructive).
 log "=== Phase 9: DELETE ==="
 step_set "delete_attempt"
-retry_curl_post "${inactivate_url}" >/dev/null \
+retry_admin runtime-rule inactivate --catalog "${CATALOG}" --name "${NAME}" 
>/dev/null \
   || fail "phase-9: inactivate-before-delete failed"
 list_row "INACTIVE" >/dev/null
-retry_curl_post "${delete_url}" >/dev/null \
+retry_admin runtime-rule delete --catalog "${CATALOG}" --name "${NAME}" 
>/dev/null \
   || fail "phase-9: delete failed"
 list_no_row
 log "  ✓ row gone + backend probe agrees"
diff --git a/test/e2e-v2/cases/storage/banyandb/e2e.yaml 
b/test/e2e-v2/cases/storage/banyandb/e2e.yaml
index 9b33becacf..c59388a5d5 100644
--- a/test/e2e-v2/cases/storage/banyandb/e2e.yaml
+++ b/test/e2e-v2/cases/storage/banyandb/e2e.yaml
@@ -64,7 +64,7 @@ verify:
     - query: swctl --display yaml 
--base-url=http://${oap_host}:${oap_12800}/graphql tv2 ls --tags 
http.method=POST,http.status_code=201
       expected: ../expected/empty-traces-v2-list.yml
     - query: |
-        curl -X GET http://${oap_host}:${oap_17128}/status/config/ttl -H 
"Accept: application/json"
+        swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
config ttl
       expected: ../expected/ttl-config-banyandb.yml
 cleanup:
   on: always
diff --git a/test/e2e-v2/cases/storage/es/e2e.yaml 
b/test/e2e-v2/cases/storage/es/e2e.yaml
index d8bb380b29..1e07021a0b 100644
--- a/test/e2e-v2/cases/storage/es/e2e.yaml
+++ b/test/e2e-v2/cases/storage/es/e2e.yaml
@@ -72,5 +72,5 @@ verify:
         )
       expected: ../expected/trace-users-detail.yml
     - query: |
-        curl -X GET http://${oap_host}:${oap_17128}/status/config/ttl -H 
"Accept: application/json"
+        swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
config ttl
       expected: ../expected/ttl-config.yml
\ No newline at end of file
diff --git a/test/e2e-v2/cases/storage/es/es-sharding/e2e.yaml 
b/test/e2e-v2/cases/storage/es/es-sharding/e2e.yaml
index f81c6ea828..cad4c17943 100644
--- a/test/e2e-v2/cases/storage/es/es-sharding/e2e.yaml
+++ b/test/e2e-v2/cases/storage/es/es-sharding/e2e.yaml
@@ -72,5 +72,5 @@ verify:
         )
       expected: ../../expected/trace-users-detail.yml
     - query: |
-        curl -X GET http://${oap_host}:${oap_17128}/status/config/ttl -H 
"Accept: application/json"
+        swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
config ttl
       expected: ../../expected/ttl-config.yml
\ No newline at end of file
diff --git a/test/e2e-v2/cases/storage/expected/config-dump.yml 
b/test/e2e-v2/cases/storage/expected/config-dump.yml
index 24cd41e5e5..a95bda9e18 100644
--- a/test/e2e-v2/cases/storage/expected/config-dump.yml
+++ b/test/e2e-v2/cases/storage/expected/config-dump.yml
@@ -13,216 +13,218 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-admin-server.default.acceptQueueSize=0
-admin-server.default.contextPath=/
-admin-server.default.gRPCHost=0.0.0.0
-admin-server.default.gRPCMaxConcurrentCallsPerConnection=0
-admin-server.default.gRPCMaxMessageSize=52428800
-admin-server.default.gRPCPort=17129
-admin-server.default.gRPCSslCertChainPath=
-admin-server.default.gRPCSslEnabled=false
-admin-server.default.gRPCSslKeyPath=
-admin-server.default.gRPCSslTrustedCAsPath=
-admin-server.default.gRPCThreadPoolSize=0
-admin-server.default.host=0.0.0.0
-admin-server.default.httpMaxRequestHeaderSize=8192
-admin-server.default.idleTimeOut=30000
-admin-server.default.internalCommunicationTimeout=5000
-admin-server.default.port=17128
-admin-server.provider=default
-agent-analyzer.default.forceSampleErrorSegment=true
-agent-analyzer.default.meterAnalyzerActiveFiles=datasource,threadpool,satellite,go-runtime,python-runtime,continuous-profiling,java-agent,go-agent,ruby-runtime
-agent-analyzer.default.noUpstreamRealAddressAgents=6000,9000
-agent-analyzer.default.segmentStatusAnalysisStrategy=FROM_SPAN_STATUS
-agent-analyzer.default.slowCacheReadThreshold=default:20,redis:10
-agent-analyzer.default.slowCacheWriteThreshold=default:20,redis:10
-agent-analyzer.default.slowDBAccessThreshold=default:200,mongodb:100
-agent-analyzer.default.traceSamplingPolicySettingsFile=trace-sampling-policy-settings.yml
-agent-analyzer.provider=default
-ai-pipeline.default.baselineServerAddr=
-ai-pipeline.default.baselineServerPort=18080
-ai-pipeline.default.uriRecognitionServerAddr=
-ai-pipeline.default.uriRecognitionServerPort=17128
-ai-pipeline.provider=default
-alarm.provider=default
-aws-firehose.default.acceptQueueSize=0
-aws-firehose.default.contextPath=/
-aws-firehose.default.enableTLS=false
-aws-firehose.default.firehoseAccessKey=******
-aws-firehose.default.host=0.0.0.0
-aws-firehose.default.idleTimeOut=30000
-aws-firehose.default.maxRequestHeaderSize=8192
-aws-firehose.default.port=12801
-aws-firehose.default.tlsCertChainPath=
-aws-firehose.default.tlsKeyPath=
-aws-firehose.provider=default
-cluster.provider=standalone
-configuration-discovery.default.disableMessageDigest=false
-configuration-discovery.provider=default
-configuration.provider=none
-core.default.activeExtraModelColumns=false
-core.default.autocompleteTagKeysQueryMaxSize=100
-core.default.autocompleteTagValuesQueryMaxSize=100
-core.default.dataKeeperExecutePeriod=5
-core.default.downsampling=[Hour, Day]
-core.default.enableDataKeeperExecutor=true
-core.default.enableEndpointNameGroupingByOpenapi=true
-core.default.enableHierarchy=true
-core.default.endpointNameMaxLength=150
-core.default.gRPCHost=0.0.0.0
-core.default.gRPCPort=11800
-core.default.gRPCSslCertChainPath=
-core.default.gRPCSslEnabled=false
-core.default.gRPCSslKeyPath=
-core.default.gRPCSslTrustedCAPath=
-core.default.gRPCThreadPoolSize=-1
-core.default.httpMaxRequestHeaderSize=8192
-core.default.instanceNameMaxLength=70
-core.default.l1FlushPeriod=500
-core.default.maxConcurrentCallsPerConnection=0
-core.default.maxDirectMemoryUsage=-1
-core.default.maxHeapMemoryUsagePercent=96
-core.default.maxHttpUrisNumberPerService=3000
-core.default.maxMessageSize=52428800
-core.default.metricsDataTTL=7
-core.default.persistentPeriod=25
-core.default.prepareThreads=2
-core.default.recordDataTTL=3
-core.default.restAcceptQueueSize=0
-core.default.restContextPath=/
-core.default.restHost=0.0.0.0
-core.default.restIdleTimeOut=30000
-core.default.restPort=12800
-core.default.role=Mixed
-core.default.searchableAlarmTags=level
-core.default.searchableLogsTags=level,http.status_code,ai_route_type
-core.default.searchableTracesTags=http.method,http.status_code,rpc.status_code,db.type,db.instance,mq.queue,mq.topic,mq.broker
-core.default.serviceCacheRefreshInterval=10
-core.default.serviceNameMaxLength=70
-core.default.storageSessionTimeout=70000
-core.default.syncPeriodHttpUriRecognitionPattern=10
-core.default.topNReportPeriod=10
-core.default.trainingPeriodHttpUriRecognitionPattern=60
-core.provider=default
-dsl-debugging.default.injectionEnabled=true
-dsl-debugging.provider=default
-envoy-metric.default.acceptMetricsService=true
-envoy-metric.default.alsHTTPAnalysis=
-envoy-metric.default.alsTCPAnalysis=
-envoy-metric.default.enabledEnvoyMetricsRules=envoy,envoy-svc-relation
-envoy-metric.default.gRPCHost=0.0.0.0
-envoy-metric.default.gRPCPort=0
-envoy-metric.default.gRPCSslCertChainPath=
-envoy-metric.default.gRPCSslEnabled=false
-envoy-metric.default.gRPCSslKeyPath=
-envoy-metric.default.gRPCSslTrustedCAsPath=
-envoy-metric.default.gRPCThreadPoolSize=0
-envoy-metric.default.istioServiceEntryIgnoredNamespaces=
-envoy-metric.default.istioServiceNameRule=${serviceEntry.metadata.name}.${serviceEntry.metadata.namespace}
-envoy-metric.default.k8sServiceNameRule=${pod.metadata.labels.(service.istio.io/canonical-name)}.${pod.metadata.namespace}
-envoy-metric.default.maxConcurrentCallsPerConnection=0
-envoy-metric.default.maxMessageSize=0
-envoy-metric.provider=default
-event-analyzer.provider=default
-gen-ai-analyzer.provider=default
-health-checker.default.checkIntervalSeconds=30
-health-checker.provider=default
-inspect.provider=default
-log-analyzer.default.lalFiles=envoy-als,mesh-dp,mysql-slowsql,pgsql-slowsql,redis-slowsql,k8s-service,nginx,envoy-ai-gateway,miniprogram,default
-log-analyzer.default.malFiles=nginx,miniprogram-wechat,miniprogram-alipay
-log-analyzer.provider=default
-logql.default.restAcceptQueueSize=0
-logql.default.restContextPath=/
-logql.default.restHost=0.0.0.0
-logql.default.restIdleTimeOut=30000
-logql.default.restPort=3100
-logql.provider=default
-promql.default.buildInfoBranch=
-promql.default.buildInfoBuildDate=
-promql.default.buildInfoBuildUser=******
-promql.default.buildInfoGoVersion=
-promql.default.buildInfoRevision=
-promql.default.buildInfoVersion=2.45.0
-promql.default.restAcceptQueueSize=0
-promql.default.restContextPath=/
-promql.default.restHost=0.0.0.0
-promql.default.restIdleTimeOut=30000
-promql.default.restPort=9090
-promql.provider=default
-query.graphql.enableLogTestTool=false
-query.graphql.enableOnDemandPodLog=false
-query.graphql.maxQueryComplexity=3000
-query.provider=graphql
-receiver-async-profiler.default.jfrMaxSize=31457280
-receiver-async-profiler.default.memoryParserEnabled=true
-receiver-async-profiler.provider=default
-receiver-browser.default.sampleRate=10000
-receiver-browser.provider=default
-receiver-clr.provider=default
-receiver-ebpf.default.continuousPolicyCacheTimeout=60
-receiver-ebpf.default.gRPCHost=0.0.0.0
-receiver-ebpf.default.gRPCPort=0
-receiver-ebpf.default.gRPCSslCertChainPath=
-receiver-ebpf.default.gRPCSslEnabled=false
-receiver-ebpf.default.gRPCSslKeyPath=
-receiver-ebpf.default.gRPCSslTrustedCAsPath=
-receiver-ebpf.default.gRPCThreadPoolSize=0
-receiver-ebpf.default.maxConcurrentCallsPerConnection=0
-receiver-ebpf.default.maxMessageSize=0
-receiver-ebpf.provider=default
-receiver-event.provider=default
-receiver-jvm.provider=default
-receiver-log.provider=default
-receiver-meter.provider=default
-receiver-otel.default.enabledHandlers=otlp-traces,otlp-metrics,otlp-logs
-receiver-otel.default.enabledOtelMetricsRules=apisix,nginx/*,k8s/*,istio-controlplane,vm,mysql/*,postgresql/*,oap,aws-eks/*,windows,aws-s3/*,aws-dynamodb/*,aws-gateway/*,redis/*,elasticsearch/*,rabbitmq/*,mongodb/*,kafka/*,pulsar/*,bookkeeper/*,rocketmq/*,clickhouse/*,activemq/*,kong/*,flink/*,banyandb/*,envoy-ai-gateway/*,ios/*,miniprogram/*
-receiver-otel.provider=default
-receiver-pprof.default.memoryParserEnabled=true
-receiver-pprof.default.pprofMaxSize=31457280
-receiver-pprof.provider=default
-receiver-profile.provider=default
-receiver-register.provider=default
-receiver-runtime-rule.default.refreshRulesPeriod=30
-receiver-runtime-rule.default.selfHealThresholdSeconds=60
-receiver-runtime-rule.provider=default
-receiver-sharing-server.default.authentication=******
-receiver-sharing-server.default.gRPCHost=0.0.0.0
-receiver-sharing-server.default.gRPCPort=0
-receiver-sharing-server.default.gRPCSslCertChainPath=
-receiver-sharing-server.default.gRPCSslEnabled=false
-receiver-sharing-server.default.gRPCSslKeyPath=
-receiver-sharing-server.default.gRPCSslTrustedCAsPath=
-receiver-sharing-server.default.gRPCThreadPoolSize=0
-receiver-sharing-server.default.httpMaxRequestHeaderSize=8192
-receiver-sharing-server.default.maxConcurrentCallsPerConnection=0
-receiver-sharing-server.default.maxMessageSize=52428800
-receiver-sharing-server.default.restAcceptQueueSize=0
-receiver-sharing-server.default.restContextPath=/
-receiver-sharing-server.default.restHost=0.0.0.0
-receiver-sharing-server.default.restIdleTimeOut=30000
-receiver-sharing-server.default.restPort=0
-receiver-sharing-server.provider=default
-receiver-telegraf.default.activeFiles=vm
-receiver-telegraf.provider=default
-receiver-trace.provider=default
-service-mesh.provider=default
-status.default.keywords4MaskingSecretsOfConfig=user,password,trustStorePass,keyStorePass,token,accessKey,secretKey,authentication
-status.provider=default
-storage.mysql.asyncBatchPersistentPoolSize=4
-storage.mysql.maxSizeOfBatchSql=2000
-storage.mysql.metadataQueryMaxSize=5000
-storage.mysql.properties.dataSource.cachePrepStmts=true
-storage.mysql.properties.dataSource.password=******
-storage.mysql.properties.dataSource.prepStmtCacheSize=250
-storage.mysql.properties.dataSource.prepStmtCacheSqlLimit=2048
-storage.mysql.properties.dataSource.useServerPrepStmts=true
-storage.mysql.properties.dataSource.user=******
-storage.mysql.properties.jdbcUrl=jdbc:mysql://mysql:3306/swtest?allowMultiQueries=true
-storage.provider=mysql
-telemetry.prometheus.host=0.0.0.0
-telemetry.prometheus.port=1234
-telemetry.prometheus.sslCertChainPath=
-telemetry.prometheus.sslEnabled=false
-telemetry.prometheus.sslKeyPath=
-telemetry.provider=prometheus
-ui-management.provider=default
+{
+  "admin-server.default.acceptQueueSize": "0",
+  "admin-server.default.contextPath": "/",
+  "admin-server.default.gRPCHost": "0.0.0.0",
+  "admin-server.default.gRPCMaxConcurrentCallsPerConnection": "0",
+  "admin-server.default.gRPCMaxMessageSize": "52428800",
+  "admin-server.default.gRPCPort": "17129",
+  "admin-server.default.gRPCSslCertChainPath": "",
+  "admin-server.default.gRPCSslEnabled": "false",
+  "admin-server.default.gRPCSslKeyPath": "",
+  "admin-server.default.gRPCSslTrustedCAsPath": "",
+  "admin-server.default.gRPCThreadPoolSize": "0",
+  "admin-server.default.host": "0.0.0.0",
+  "admin-server.default.httpMaxRequestHeaderSize": "8192",
+  "admin-server.default.idleTimeOut": "30000",
+  "admin-server.default.internalCommunicationTimeout": "5000",
+  "admin-server.default.port": "17128",
+  "admin-server.provider": "default",
+  "agent-analyzer.default.forceSampleErrorSegment": "true",
+  "agent-analyzer.default.meterAnalyzerActiveFiles": 
"datasource,threadpool,satellite,go-runtime,python-runtime,continuous-profiling,java-agent,go-agent,ruby-runtime",
+  "agent-analyzer.default.noUpstreamRealAddressAgents": "6000,9000",
+  "agent-analyzer.default.segmentStatusAnalysisStrategy": "FROM_SPAN_STATUS",
+  "agent-analyzer.default.slowCacheReadThreshold": "default:20,redis:10",
+  "agent-analyzer.default.slowCacheWriteThreshold": "default:20,redis:10",
+  "agent-analyzer.default.slowDBAccessThreshold": "default:200,mongodb:100",
+  "agent-analyzer.default.traceSamplingPolicySettingsFile": 
"trace-sampling-policy-settings.yml",
+  "agent-analyzer.provider": "default",
+  "ai-pipeline.default.baselineServerAddr": "",
+  "ai-pipeline.default.baselineServerPort": "18080",
+  "ai-pipeline.default.uriRecognitionServerAddr": "",
+  "ai-pipeline.default.uriRecognitionServerPort": "17128",
+  "ai-pipeline.provider": "default",
+  "alarm.provider": "default",
+  "aws-firehose.default.acceptQueueSize": "0",
+  "aws-firehose.default.contextPath": "/",
+  "aws-firehose.default.enableTLS": "false",
+  "aws-firehose.default.firehoseAccessKey": "******",
+  "aws-firehose.default.host": "0.0.0.0",
+  "aws-firehose.default.idleTimeOut": "30000",
+  "aws-firehose.default.maxRequestHeaderSize": "8192",
+  "aws-firehose.default.port": "12801",
+  "aws-firehose.default.tlsCertChainPath": "",
+  "aws-firehose.default.tlsKeyPath": "",
+  "aws-firehose.provider": "default",
+  "cluster.provider": "standalone",
+  "configuration-discovery.default.disableMessageDigest": "false",
+  "configuration-discovery.provider": "default",
+  "configuration.provider": "none",
+  "core.default.activeExtraModelColumns": "false",
+  "core.default.autocompleteTagKeysQueryMaxSize": "100",
+  "core.default.autocompleteTagValuesQueryMaxSize": "100",
+  "core.default.dataKeeperExecutePeriod": "5",
+  "core.default.downsampling": "[Hour, Day]",
+  "core.default.enableDataKeeperExecutor": "true",
+  "core.default.enableEndpointNameGroupingByOpenapi": "true",
+  "core.default.enableHierarchy": "true",
+  "core.default.endpointNameMaxLength": "150",
+  "core.default.gRPCHost": "0.0.0.0",
+  "core.default.gRPCPort": "11800",
+  "core.default.gRPCSslCertChainPath": "",
+  "core.default.gRPCSslEnabled": "false",
+  "core.default.gRPCSslKeyPath": "",
+  "core.default.gRPCSslTrustedCAPath": "",
+  "core.default.gRPCThreadPoolSize": "-1",
+  "core.default.httpMaxRequestHeaderSize": "8192",
+  "core.default.instanceNameMaxLength": "70",
+  "core.default.l1FlushPeriod": "500",
+  "core.default.maxConcurrentCallsPerConnection": "0",
+  "core.default.maxDirectMemoryUsage": "-1",
+  "core.default.maxHeapMemoryUsagePercent": "96",
+  "core.default.maxHttpUrisNumberPerService": "3000",
+  "core.default.maxMessageSize": "52428800",
+  "core.default.metricsDataTTL": "7",
+  "core.default.persistentPeriod": "25",
+  "core.default.prepareThreads": "2",
+  "core.default.recordDataTTL": "3",
+  "core.default.restAcceptQueueSize": "0",
+  "core.default.restContextPath": "/",
+  "core.default.restHost": "0.0.0.0",
+  "core.default.restIdleTimeOut": "30000",
+  "core.default.restPort": "12800",
+  "core.default.role": "Mixed",
+  "core.default.searchableAlarmTags": "level",
+  "core.default.searchableLogsTags": "level,http.status_code,ai_route_type",
+  "core.default.searchableTracesTags": 
"http.method,http.status_code,rpc.status_code,db.type,db.instance,mq.queue,mq.topic,mq.broker",
+  "core.default.serviceCacheRefreshInterval": "10",
+  "core.default.serviceNameMaxLength": "70",
+  "core.default.storageSessionTimeout": "70000",
+  "core.default.syncPeriodHttpUriRecognitionPattern": "10",
+  "core.default.topNReportPeriod": "10",
+  "core.default.trainingPeriodHttpUriRecognitionPattern": "60",
+  "core.provider": "default",
+  "dsl-debugging.default.injectionEnabled": "true",
+  "dsl-debugging.provider": "default",
+  "envoy-metric.default.acceptMetricsService": "true",
+  "envoy-metric.default.alsHTTPAnalysis": "",
+  "envoy-metric.default.alsTCPAnalysis": "",
+  "envoy-metric.default.enabledEnvoyMetricsRules": "envoy,envoy-svc-relation",
+  "envoy-metric.default.gRPCHost": "0.0.0.0",
+  "envoy-metric.default.gRPCPort": "0",
+  "envoy-metric.default.gRPCSslCertChainPath": "",
+  "envoy-metric.default.gRPCSslEnabled": "false",
+  "envoy-metric.default.gRPCSslKeyPath": "",
+  "envoy-metric.default.gRPCSslTrustedCAsPath": "",
+  "envoy-metric.default.gRPCThreadPoolSize": "0",
+  "envoy-metric.default.istioServiceEntryIgnoredNamespaces": "",
+  "envoy-metric.default.istioServiceNameRule": 
"${serviceEntry.metadata.name}.${serviceEntry.metadata.namespace}",
+  "envoy-metric.default.k8sServiceNameRule": 
"${pod.metadata.labels.(service.istio.io/canonical-name)}.${pod.metadata.namespace}",
+  "envoy-metric.default.maxConcurrentCallsPerConnection": "0",
+  "envoy-metric.default.maxMessageSize": "0",
+  "envoy-metric.provider": "default",
+  "event-analyzer.provider": "default",
+  "gen-ai-analyzer.provider": "default",
+  "health-checker.default.checkIntervalSeconds": "30",
+  "health-checker.provider": "default",
+  "inspect.provider": "default",
+  "log-analyzer.default.lalFiles": 
"envoy-als,mesh-dp,mysql-slowsql,pgsql-slowsql,redis-slowsql,k8s-service,nginx,envoy-ai-gateway,miniprogram,default",
+  "log-analyzer.default.malFiles": 
"nginx,miniprogram-wechat,miniprogram-alipay",
+  "log-analyzer.provider": "default",
+  "logql.default.restAcceptQueueSize": "0",
+  "logql.default.restContextPath": "/",
+  "logql.default.restHost": "0.0.0.0",
+  "logql.default.restIdleTimeOut": "30000",
+  "logql.default.restPort": "3100",
+  "logql.provider": "default",
+  "promql.default.buildInfoBranch": "",
+  "promql.default.buildInfoBuildDate": "",
+  "promql.default.buildInfoBuildUser": "******",
+  "promql.default.buildInfoGoVersion": "",
+  "promql.default.buildInfoRevision": "",
+  "promql.default.buildInfoVersion": "2.45.0",
+  "promql.default.restAcceptQueueSize": "0",
+  "promql.default.restContextPath": "/",
+  "promql.default.restHost": "0.0.0.0",
+  "promql.default.restIdleTimeOut": "30000",
+  "promql.default.restPort": "9090",
+  "promql.provider": "default",
+  "query.graphql.enableLogTestTool": "false",
+  "query.graphql.enableOnDemandPodLog": "false",
+  "query.graphql.maxQueryComplexity": "3000",
+  "query.provider": "graphql",
+  "receiver-async-profiler.default.jfrMaxSize": "31457280",
+  "receiver-async-profiler.default.memoryParserEnabled": "true",
+  "receiver-async-profiler.provider": "default",
+  "receiver-browser.default.sampleRate": "10000",
+  "receiver-browser.provider": "default",
+  "receiver-clr.provider": "default",
+  "receiver-ebpf.default.continuousPolicyCacheTimeout": "60",
+  "receiver-ebpf.default.gRPCHost": "0.0.0.0",
+  "receiver-ebpf.default.gRPCPort": "0",
+  "receiver-ebpf.default.gRPCSslCertChainPath": "",
+  "receiver-ebpf.default.gRPCSslEnabled": "false",
+  "receiver-ebpf.default.gRPCSslKeyPath": "",
+  "receiver-ebpf.default.gRPCSslTrustedCAsPath": "",
+  "receiver-ebpf.default.gRPCThreadPoolSize": "0",
+  "receiver-ebpf.default.maxConcurrentCallsPerConnection": "0",
+  "receiver-ebpf.default.maxMessageSize": "0",
+  "receiver-ebpf.provider": "default",
+  "receiver-event.provider": "default",
+  "receiver-jvm.provider": "default",
+  "receiver-log.provider": "default",
+  "receiver-meter.provider": "default",
+  "receiver-otel.default.enabledHandlers": 
"otlp-traces,otlp-metrics,otlp-logs",
+  "receiver-otel.default.enabledOtelMetricsRules": 
"apisix,nginx/*,k8s/*,istio-controlplane,vm,mysql/*,postgresql/*,oap,aws-eks/*,windows,aws-s3/*,aws-dynamodb/*,aws-gateway/*,redis/*,elasticsearch/*,rabbitmq/*,mongodb/*,kafka/*,pulsar/*,bookkeeper/*,rocketmq/*,clickhouse/*,activemq/*,kong/*,flink/*,banyandb/*,envoy-ai-gateway/*,ios/*,miniprogram/*",
+  "receiver-otel.provider": "default",
+  "receiver-pprof.default.memoryParserEnabled": "true",
+  "receiver-pprof.default.pprofMaxSize": "31457280",
+  "receiver-pprof.provider": "default",
+  "receiver-profile.provider": "default",
+  "receiver-register.provider": "default",
+  "receiver-runtime-rule.default.refreshRulesPeriod": "30",
+  "receiver-runtime-rule.default.selfHealThresholdSeconds": "60",
+  "receiver-runtime-rule.provider": "default",
+  "receiver-sharing-server.default.authentication": "******",
+  "receiver-sharing-server.default.gRPCHost": "0.0.0.0",
+  "receiver-sharing-server.default.gRPCPort": "0",
+  "receiver-sharing-server.default.gRPCSslCertChainPath": "",
+  "receiver-sharing-server.default.gRPCSslEnabled": "false",
+  "receiver-sharing-server.default.gRPCSslKeyPath": "",
+  "receiver-sharing-server.default.gRPCSslTrustedCAsPath": "",
+  "receiver-sharing-server.default.gRPCThreadPoolSize": "0",
+  "receiver-sharing-server.default.httpMaxRequestHeaderSize": "8192",
+  "receiver-sharing-server.default.maxConcurrentCallsPerConnection": "0",
+  "receiver-sharing-server.default.maxMessageSize": "52428800",
+  "receiver-sharing-server.default.restAcceptQueueSize": "0",
+  "receiver-sharing-server.default.restContextPath": "/",
+  "receiver-sharing-server.default.restHost": "0.0.0.0",
+  "receiver-sharing-server.default.restIdleTimeOut": "30000",
+  "receiver-sharing-server.default.restPort": "0",
+  "receiver-sharing-server.provider": "default",
+  "receiver-telegraf.default.activeFiles": "vm",
+  "receiver-telegraf.provider": "default",
+  "receiver-trace.provider": "default",
+  "service-mesh.provider": "default",
+  "status.default.keywords4MaskingSecretsOfConfig": 
"user,password,trustStorePass,keyStorePass,token,accessKey,secretKey,authentication",
+  "status.provider": "default",
+  "storage.mysql.asyncBatchPersistentPoolSize": "4",
+  "storage.mysql.maxSizeOfBatchSql": "2000",
+  "storage.mysql.metadataQueryMaxSize": "5000",
+  "storage.mysql.properties.dataSource.cachePrepStmts": "true",
+  "storage.mysql.properties.dataSource.password": "******",
+  "storage.mysql.properties.dataSource.prepStmtCacheSize": "250",
+  "storage.mysql.properties.dataSource.prepStmtCacheSqlLimit": "2048",
+  "storage.mysql.properties.dataSource.useServerPrepStmts": "true",
+  "storage.mysql.properties.dataSource.user": "******",
+  "storage.mysql.properties.jdbcUrl": 
"jdbc:mysql://mysql:3306/swtest?allowMultiQueries=true",
+  "storage.provider": "mysql",
+  "telemetry.prometheus.host": "0.0.0.0",
+  "telemetry.prometheus.port": "1234",
+  "telemetry.prometheus.sslCertChainPath": "",
+  "telemetry.prometheus.sslEnabled": "false",
+  "telemetry.prometheus.sslKeyPath": "",
+  "telemetry.provider": "prometheus",
+  "ui-management.provider": "default"
+}
diff --git a/test/e2e-v2/cases/storage/mysql/e2e.yaml 
b/test/e2e-v2/cases/storage/mysql/e2e.yaml
index c6f14c5162..423c3c6024 100644
--- a/test/e2e-v2/cases/storage/mysql/e2e.yaml
+++ b/test/e2e-v2/cases/storage/mysql/e2e.yaml
@@ -69,8 +69,7 @@ verify:
             | yq e '.traces[0].traceids[0]' - \
         )
       expected: ../expected/trace-users-detail.yml
-    - query: curl -X GET http://${oap_host}:${oap_17128}/debugging/config/dump
+    - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128} 
admin config dump
       expected: ../expected/config-dump.yml
-    - query: |
-        curl -X GET http://${oap_host}:${oap_17128}/status/config/ttl -H 
"Accept: application/json"
+    - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128} 
admin config ttl
       expected: ../expected/ttl-config.yml
\ No newline at end of file
diff --git a/test/e2e-v2/cases/storage/opensearch/e2e.yaml 
b/test/e2e-v2/cases/storage/opensearch/e2e.yaml
index d8bb380b29..1e07021a0b 100644
--- a/test/e2e-v2/cases/storage/opensearch/e2e.yaml
+++ b/test/e2e-v2/cases/storage/opensearch/e2e.yaml
@@ -72,5 +72,5 @@ verify:
         )
       expected: ../expected/trace-users-detail.yml
     - query: |
-        curl -X GET http://${oap_host}:${oap_17128}/status/config/ttl -H 
"Accept: application/json"
+        swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
config ttl
       expected: ../expected/ttl-config.yml
\ No newline at end of file
diff --git a/test/e2e-v2/cases/storage/postgres/e2e.yaml 
b/test/e2e-v2/cases/storage/postgres/e2e.yaml
index 5fe085caf1..bbc791aeb1 100644
--- a/test/e2e-v2/cases/storage/postgres/e2e.yaml
+++ b/test/e2e-v2/cases/storage/postgres/e2e.yaml
@@ -70,5 +70,5 @@ verify:
         )
       expected: ../expected/trace-users-detail.yml
     - query: |
-        curl -X GET http://${oap_host}:${oap_17128}/status/config/ttl -H 
"Accept: application/json"
+        swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
config ttl
       expected: ../expected/ttl-config.yml
\ No newline at end of file
diff --git a/test/e2e-v2/cases/storage/storage-cases.yaml 
b/test/e2e-v2/cases/storage/storage-cases.yaml
index 36203e5677..799125791a 100644
--- a/test/e2e-v2/cases/storage/storage-cases.yaml
+++ b/test/e2e-v2/cases/storage/storage-cases.yaml
@@ -167,58 +167,61 @@ cases:
   # PostgreSQL).
   # =====================================================================
 
-  # /inspect/metrics — catalog endpoint, asserts service_cpm shows the
+  # inspect metrics — catalog endpoint, asserts service_cpm shows the
   # expected type / scope / catalog / supported downsamplings.
-  - query: curl -s 
"http://${oap_host}:${oap_17128}/inspect/metrics?regex=service_cpm"; | yq -P
+  - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128} 
admin inspect metrics --regex service_cpm | yq -P
     expected: expected/inspect-metrics.yml
-  # /inspect/entities — Service scope, step=DAY (single bucket = today UTC).
+  # inspect entities — Service scope, step=DAY (single bucket = today UTC).
   # Avoids the framework's MAX_TIME_RANGE=500 cap and works on any host shell
   # (BSD date on macOS, GNU date in CI; both accept `date -u +%Y-%m-%d`).
   - query: |
       DAY=$(date -u +"%Y-%m-%d")
-      curl -s 
"http://${oap_host}:${oap_17128}/inspect/entities?metric=service_cpm&start=${DAY}&end=${DAY}&step=DAY";
 | yq -P
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
inspect entities --metric service_cpm --start "${DAY}" --end "${DAY}" --step 
DAY | yq -P
     expected: expected/inspect-entities-service-cpm.yml
   # ServiceInstance scope — instance-id decode + parent-service layer lookup.
   - query: |
       DAY=$(date -u +"%Y-%m-%d")
-      curl -s 
"http://${oap_host}:${oap_17128}/inspect/entities?metric=service_instance_cpm&start=${DAY}&end=${DAY}&step=DAY";
 | yq -P
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
inspect entities --metric service_instance_cpm --start "${DAY}" --end "${DAY}" 
--step DAY | yq -P
     expected: expected/inspect-entities-service-instance-cpm.yml
   # Endpoint scope — endpoint-id decode.
   - query: |
       DAY=$(date -u +"%Y-%m-%d")
-      curl -s 
"http://${oap_host}:${oap_17128}/inspect/entities?metric=endpoint_cpm&start=${DAY}&end=${DAY}&step=DAY";
 | yq -P
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
inspect entities --metric endpoint_cpm --start "${DAY}" --end "${DAY}" --step 
DAY | yq -P
     expected: expected/inspect-entities-endpoint-cpm.yml
   # ServiceRelation scope — analysisRelationId decode + paired 
source/destination
   # shape + MqeEntity dest* fields.
   - query: |
       DAY=$(date -u +"%Y-%m-%d")
-      curl -s 
"http://${oap_host}:${oap_17128}/inspect/entities?metric=service_relation_client_cpm&start=${DAY}&end=${DAY}&step=DAY";
 | yq -P
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
inspect entities --metric service_relation_client_cpm --start "${DAY}" --end 
"${DAY}" --step DAY | yq -P
     expected: expected/inspect-entities-service-relation-cpm.yml
   # LABELED_VALUE metric — Service-scope entity decode is identical but the
   # storage value column shape differs (BanyanDB DataTable, ES JSON, JDBC
   # VARCHAR).
   - query: |
       DAY=$(date -u +"%Y-%m-%d")
-      curl -s 
"http://${oap_host}:${oap_17128}/inspect/entities?metric=service_percentile&start=${DAY}&end=${DAY}&step=DAY";
 | yq -P
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
inspect entities --metric service_percentile --start "${DAY}" --end "${DAY}" 
--step DAY | yq -P
     expected: expected/inspect-entities-service-percentile.yml
   # step=MINUTE over a 20-minute window. Exercises minute-precision range.
   # python3 keeps the date math portable across BSD / GNU date.
   - query: |
-      START=$(python3 -c "import datetime as d; print((d.datetime.utcnow() - 
d.timedelta(minutes=20)).strftime('%Y-%m-%d %H%M'))" | sed 's/ /%20/')
-      END=$(python3 -c "import datetime as d; 
print(d.datetime.utcnow().strftime('%Y-%m-%d %H%M'))" | sed 's/ /%20/')
-      curl -s 
"http://${oap_host}:${oap_17128}/inspect/entities?metric=service_cpm&start=${START}&end=${END}&step=MINUTE";
 | yq -P
+      START=$(python3 -c "import datetime as d; print((d.datetime.utcnow() - 
d.timedelta(minutes=20)).strftime('%Y-%m-%d %H%M'))")
+      END=$(python3 -c "import datetime as d; 
print(d.datetime.utcnow().strftime('%Y-%m-%d %H%M'))")
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
inspect entities --metric service_cpm --start "${START}" --end "${END}" --step 
MINUTE | yq -P
     expected: expected/inspect-entities-service-cpm-minute.yml
   # step=HOUR over a full-day window (00 → 23 UTC). Avoids the flakiness of
   # a sliding 2-hour window — postgres / slow downsamplers may not have
   # populated the *current* HOUR bucket yet, but at least one earlier HOUR
   # bucket in today is guaranteed once the MINUTE case has data.
   - query: |
-      START=$(date -u +"%Y-%m-%d")%2000
-      END=$(date -u +"%Y-%m-%d")%2023
-      curl -s 
"http://${oap_host}:${oap_17128}/inspect/entities?metric=service_cpm&start=${START}&end=${END}&step=HOUR";
 | yq -P
+      DAY=$(date -u +"%Y-%m-%d")
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} admin 
inspect entities --metric service_cpm --start "${DAY} 00" --end "${DAY} 23" 
--step HOUR | yq -P
     expected: expected/inspect-entities-service-cpm-hour.yml
-  # Negative — unknown metric returns a 400-shaped error JSON.
+  # Negative — unknown metric: swctl exits non-zero and renders the typed error
+  # envelope ("... HTTP 400: unknown metric: <name>"). Reconstruct the original
+  # {error: ...} shape from the message so the expected file is unchanged.
   - query: |
       DAY=$(date -u +"%Y-%m-%d")
-      curl -s 
"http://${oap_host}:${oap_17128}/inspect/entities?metric=nonexistent_metric_xyz&start=${DAY}&end=${DAY}&step=DAY";
 | yq -P
+      out=$(swctl --display yaml --admin-url=http://${oap_host}:${oap_17128} 
admin inspect entities --metric nonexistent_metric_xyz --start "${DAY}" --end 
"${DAY}" --step DAY 2>&1 || true)
+      msg=$(echo "$out" | grep -o 'unknown metric: [a-z_0-9]*' | head -1)
+      yq -n ".error = \"${msg}\""
     expected: expected/inspect-entities-unknown-metric.yml
diff --git a/test/e2e-v2/cases/ui-management/banyandb/e2e.yaml 
b/test/e2e-v2/cases/ui-management/banyandb/e2e.yaml
index e51a409176..84a8c3776f 100644
--- a/test/e2e-v2/cases/ui-management/banyandb/e2e.yaml
+++ b/test/e2e-v2/cases/ui-management/banyandb/e2e.yaml
@@ -15,7 +15,7 @@
 
 # Exercises the ui-management REST surface end-to-end against a fresh OAP +
 # BanyanDB stack. No agent traffic is needed — the test drives the admin host
-# directly with curl, walking the full template CRUD cycle.
+# directly with `swctl admin ui-template`, walking the full template CRUD 
cycle.
 
 setup:
   env: compose
@@ -27,6 +27,8 @@ setup:
       command: export PATH=/tmp/skywalking-infra-e2e/bin:$PATH
     - name: install yq
       command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh yq
+    - name: install swctl
+      command: bash test/e2e-v2/script/prepare/setup-e2e-shell/install.sh swctl
 
 verify:
   # ui-management's start() waits on BanyanDB to install the ui_template
diff --git a/test/e2e-v2/cases/ui-management/ui-management-cases.yaml 
b/test/e2e-v2/cases/ui-management/ui-management-cases.yaml
index 82aca58ce0..bc572ce17f 100644
--- a/test/e2e-v2/cases/ui-management/ui-management-cases.yaml
+++ b/test/e2e-v2/cases/ui-management/ui-management-cases.yaml
@@ -13,61 +13,60 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-# REST surface for dashboard templates, mounted on the admin-server REST host
-# (default :17128). The cases below walk add → get → list → change → disable →
-# list-default vs. list-with-disabled to exercise the full CRUD cycle. The
-# client supplies the template id on POST; subsequent steps read it back from
-# /tmp/uitpl-add.json.
+# Dashboard-template CRUD over the admin-server `ui-management` module, driven 
by
+# `swctl admin ui-template ...` instead of raw curl. The cases walk
+# create -> get -> update -> get -> disable -> list-default vs. 
list-with-disabled
+# to exercise the full cycle. The client supplies a fixed template id on 
create so
+# the later steps address it without reading it back.
 
 cases:
-  # 1. POST a new template — client supplies the id.
+  # 1. Create a new template — the client supplies the id; the configuration is
+  #    passed as a file (swctl reads the JSON-encoded body from --file).
   - query: |
-      curl -s -X POST http://${oap_host}:${oap_17128}/ui-management/templates \
-        -H 'Content-Type: application/json' \
-        -d 
'{"id":"e2e-tpl-id","configuration":"{\"name\":\"e2e-tpl\",\"v\":1}"}' \
-        | tee /tmp/uitpl-add.json \
-        | yq -P '. | {"status": .status, "hasId": (.id != null and .id != "")}'
+      printf '%s' '{"name":"e2e-tpl","v":1}' > /tmp/uitpl-cfg.json
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} \
+        admin ui-template create --id e2e-tpl-id -f /tmp/uitpl-cfg.json \
+        | yq -P '{"status": .status, "hasId": (.id != null and .id != "")}'
     expected: expected/template-write-success.yml
 
-  # 2. GET the template by the id returned from step 1.
+  # 2. Get the template by the fixed id.
   - query: |
-      ID=$(yq -r '.id' /tmp/uitpl-add.json);
-      curl -s -X GET 
http://${oap_host}:${oap_17128}/ui-management/templates/$ID \
-        | yq -P '. | {"configuration": .configuration, "disabled": .disabled}'
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} \
+        admin ui-template get e2e-tpl-id \
+        | yq -P '{"configuration": .configuration, "disabled": .disabled}'
     expected: expected/template-fetched.yml
 
-  # 3. PUT to update the configuration. Same id, new body.
+  # 3. Update the configuration. Same id, new body.
   - query: |
-      ID=$(yq -r '.id' /tmp/uitpl-add.json);
-      curl -s -X PUT http://${oap_host}:${oap_17128}/ui-management/templates \
-        -H 'Content-Type: application/json' \
-        -d 
"{\"id\":\"$ID\",\"configuration\":\"{\\\"name\\\":\\\"e2e-tpl\\\",\\\"v\\\":2}\"}"
 \
-        | yq -P '. | {"status": .status, "id": .id}'
+      printf '%s' '{"name":"e2e-tpl","v":2}' > /tmp/uitpl-cfg2.json
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} \
+        admin ui-template update --id e2e-tpl-id -f /tmp/uitpl-cfg2.json \
+        | yq -P '{"status": .status, "id": .id}'
     expected: expected/template-changed.yml
 
-  # 4. GET again — configuration should now reflect the PUT body.
+  # 4. Get again — configuration should now reflect the update.
   - query: |
-      ID=$(yq -r '.id' /tmp/uitpl-add.json);
-      curl -s -X GET 
http://${oap_host}:${oap_17128}/ui-management/templates/$ID \
-        | yq -P '. | {"configuration": .configuration, "disabled": .disabled}'
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} \
+        admin ui-template get e2e-tpl-id \
+        | yq -P '{"configuration": .configuration, "disabled": .disabled}'
     expected: expected/template-updated.yml
 
-  # 5. POST .../disable — soft-delete.
+  # 5. Disable — soft-delete.
   - query: |
-      ID=$(yq -r '.id' /tmp/uitpl-add.json);
-      curl -s -X POST 
http://${oap_host}:${oap_17128}/ui-management/templates/$ID/disable \
-        | yq -P '. | {"status": .status, "id": .id}'
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} \
+        admin ui-template disable e2e-tpl-id \
+        | yq -P '{"status": .status, "id": .id}'
     expected: expected/template-changed.yml
 
   # 6. Default list excludes disabled — should be empty.
   - query: |
-      curl -s -X GET http://${oap_host}:${oap_17128}/ui-management/templates \
-        | yq -P '. | length'
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} \
+        admin ui-template list | yq -P '. | length'
     expected: expected/template-list-empty.yml
 
-  # 7. includingDisabled=true returns the soft-deleted entry.
+  # 7. --include-disabled returns the soft-deleted entry.
   - query: |
-      ID=$(yq -r '.id' /tmp/uitpl-add.json);
-      curl -s -X GET 
"http://${oap_host}:${oap_17128}/ui-management/templates?includingDisabled=true";
 \
-        | yq -P '.[] | select(.id == "'"$ID"'") | {"disabled": .disabled}'
+      swctl --display json --admin-url=http://${oap_host}:${oap_17128} \
+        admin ui-template list --include-disabled \
+        | yq -P '.[] | select(.id == "e2e-tpl-id") | {"disabled": .disabled}'
     expected: expected/template-list-disabled.yml
diff --git a/test/e2e-v2/script/env b/test/e2e-v2/script/env
index 0944fddff9..d51720c7b9 100644
--- a/test/e2e-v2/script/env
+++ b/test/e2e-v2/script/env
@@ -27,7 +27,7 @@ SW_BANYANDB_COMMIT=84b919efca3fee3d51df9e97a734a9f10ae6f1d2
 SW_AGENT_PHP_COMMIT=d1114e7be5d89881eec76e5b56e69ff844691e35
 SW_PREDICTOR_COMMIT=54a0197654a3781a6f73ce35146c712af297c994
 
-SW_CTL_COMMIT=9a1beab08413ce415a00a8547a238a14691c5655
+SW_CTL_COMMIT=b447211a9319eeb29a445335e9c2536f8c1aa23d
 
 # Third-party image versions used by e2e infrastructure (not skywalking
 # components). Pinned here so the matrix is reproducible.


Reply via email to