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-cli.git
The following commit(s) were added to refs/heads/master by this push:
new 612a2df Support the OAP admin-server REST API (`swctl admin ...`) and
adapt to OAP 11.0.0 (#228)
612a2df is described below
commit 612a2dfe27843f33e2a8bb274eae69affaf674a6
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Wed Jun 3 15:01:50 2026 +0800
Support the OAP admin-server REST API (`swctl admin ...`) and adapt to OAP
11.0.0 (#228)
* Support the OAP admin-server REST API and adapt to OAP 11.0.0
Add a first-class admin-host (REST) client and a `swctl admin ...` command
tree, alongside the existing GraphQL surface, and adapt to the OAP 11.0.0
breaking changes.
Admin REST surface (default port 17128):
- New global `--admin-url` flag (env SW_ADMIN_URL / config key `admin-url`),
derived from `--base-url`'s host with port 17128 when unset.
- New pkg/transport (shared TLS/basic-auth) and pkg/admin/client REST client
with a typed error envelope and admin-module preflight detection.
- `swctl admin ...` covering every feature module: preflight; cluster nodes,
config dump/ttl, alarm rules/rule (status); inspect metrics/entities;
ui-template list/get/create/update/disable; runtime-rule
list/bundled/get/add/inactivate/delete/dump; dsl-debug status/sessions/
session start|get|stop and oal files/file/rules/rule.
OAP 11.0.0 adaptations:
- alarm list: migrate getAlarm -> queryAlarms, add --layer/--rules filters.
- menu get: detect the retired getMenuItems and report a clear message
instead of a raw GraphQL error.
E2E:
- Bump OAP to 11.0.0+, switch storage from Elasticsearch to BanyanDB.
- basic: layer list normalized via `yq sort`; trace cases migrated to
trace-v2 (BanyanDB rejects the v1 trace API).
- New `admin` case (static admin REST) and `live-debugging` case (OAL live
capture, asserting the captured pipeline is exactly the bound metric).
- New admin-command-tests and live-debugging-tests CI jobs.
---
.github/workflows/CI.yaml | 58 ++++-
CHANGES.md | 3 +
README.md | 1 +
assets/graphqls/alarm/alarms.graphql | 4 +-
cmd/swctl/main.go | 11 +
examples/.skywalking.yaml | 3 +
internal/commands/admin/admin.go | 59 +++++
internal/commands/admin/alarm/alarm.go | 98 ++++++++
.../{menu/get.go => admin/cluster/cluster.go} | 35 +--
internal/commands/admin/config/config.go | 71 ++++++
internal/commands/admin/dsldebug/dsldebug.go | 185 +++++++++++++++
internal/commands/admin/inspect/inspect.go | 132 +++++++++++
internal/commands/admin/oal/oal.go | 105 +++++++++
.../commands/{menu/get.go => admin/preflight.go} | 32 +--
internal/commands/admin/runtimerule/runtimerule.go | 251 +++++++++++++++++++++
internal/commands/admin/uitemplate/uitemplate.go | 192 ++++++++++++++++
internal/commands/alarm/list.go | 40 +++-
internal/commands/interceptor/interceptor.go | 3 +
internal/commands/menu/get.go | 30 +++
pkg/admin/client/client.go | 238 +++++++++++++++++++
pkg/admin/client/client_test.go | 79 +++++++
pkg/admin/dsldebug/dsldebug.go | 110 +++++++++
pkg/admin/inspect/inspect.go | 123 ++++++++++
pkg/admin/oal/oal.go | 67 ++++++
pkg/admin/preflight/preflight.go | 121 ++++++++++
pkg/admin/runtimerule/runtimerule.go | 211 +++++++++++++++++
pkg/admin/status/status.go | 115 ++++++++++
pkg/admin/uitemplate/uitemplate.go | 92 ++++++++
pkg/contextkey/contextkey.go | 1 +
pkg/graphql/alarm/alarm.go | 47 +++-
pkg/graphql/client/client.go | 32 +--
pkg/transport/transport.go | 71 ++++++
test/base/docker-compose.yml | 22 +-
test/cases/{basic => admin}/docker-compose.yml | 33 +--
.../traces-list.yml => admin/expected/count.yml} | 13 +-
.../traces-list.yml => admin/expected/ok.yml} | 13 +-
.../traces-list.yml => admin/expected/true.yml} | 13 +-
test/cases/admin/test.yaml | 72 ++++++
test/cases/basic/docker-compose.yml | 8 +-
test/cases/basic/expected/layer-list.yml | 79 ++++---
test/cases/basic/expected/trace-users-detail.yml | 103 ---------
test/cases/basic/test.yaml | 18 +-
.../{basic => live-debugging}/docker-compose.yml | 16 +-
.../expected/ok.yml} | 13 +-
test/cases/live-debugging/oal-debug-flow.sh | 116 ++++++++++
test/cases/live-debugging/test.yaml | 46 ++++
46 files changed, 2865 insertions(+), 320 deletions(-)
diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml
index b8960e4..f768792 100644
--- a/.github/workflows/CI.yaml
+++ b/.github/workflows/CI.yaml
@@ -30,6 +30,12 @@ concurrency:
group: skywalking-cli-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
+# OAP image tag used by the E2E suites. Must be an admin-capable build: the
admin-server
+# REST host (port 17128), queryAlarms and the menu retirement landed in OAP
11.0.0; the
+# OAL live-debugging case additionally needs the demo traffic to drive the
captured pipeline.
+env:
+ OAP_TAG: 768d0693aac34ed49ce4d1c89e4a56df353e4140
+
jobs:
check-license:
name: License header
@@ -85,10 +91,6 @@ jobs:
name: Command Tests
runs-on: ubuntu-latest
if: github.repository == 'apache/skywalking-cli'
- strategy:
- matrix:
- oap:
- - 42c613bea94999a6cc8e805ed4c8c7659f3a735c
steps:
- uses: actions/checkout@v4
- name: Set up Go
@@ -101,12 +103,50 @@ jobs:
- name: Test commands
uses:
apache/skywalking-infra-e2e@cf589b4a0b9f8e6f436f78e9cfd94a1ee5494180
- env:
- OAP_TAG: ${{ matrix.oap }}
with:
e2e-file: test/cases/basic/test.yaml
+ admin-command-tests:
+ name: Admin Command Tests
+ runs-on: ubuntu-latest
+ if: github.repository == 'apache/skywalking-cli'
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.26"
+
+ - name: Install swctl
+ run: make install DESTDIR=/usr/local/bin
+
+ - name: Test admin commands
+ uses:
apache/skywalking-infra-e2e@cf589b4a0b9f8e6f436f78e9cfd94a1ee5494180
+ with:
+ e2e-file: test/cases/admin/test.yaml
+
+
+ live-debugging-tests:
+ name: Live Debugging Tests
+ runs-on: ubuntu-latest
+ if: github.repository == 'apache/skywalking-cli'
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: "1.26"
+
+ - name: Install swctl
+ run: make install DESTDIR=/usr/local/bin
+
+ - name: Test OAL live debugging
+ uses:
apache/skywalking-infra-e2e@cf589b4a0b9f8e6f436f78e9cfd94a1ee5494180
+ with:
+ e2e-file: test/cases/live-debugging/test.yaml
+
+
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
@@ -130,6 +170,8 @@ jobs:
- check-license
- build
- command-tests
+ - admin-command-tests
+ - live-debugging-tests
- unit-tests
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -140,8 +182,12 @@ jobs:
[[ ${checkLicense} == 'success' ]] || exit 1;
build=${{ needs.build.result }};
commandTests=${{ needs.command-tests.result }};
+ adminCommandTests=${{ needs.admin-command-tests.result }};
+ liveDebuggingTests=${{ needs.live-debugging-tests.result }};
unitTests=${{ needs.unit-tests.result }};
[[ ${build} == 'success' ]] || [[ ${build} == 'skipped' ]] || exit 3;
[[ ${commandTests} == 'success' ]] || [[ ${commandTests} ==
'skipped' ]] || exit 4;
+ [[ ${adminCommandTests} == 'success' ]] || [[ ${adminCommandTests}
== 'skipped' ]] || exit 6;
+ [[ ${liveDebuggingTests} == 'success' ]] || [[ ${liveDebuggingTests}
== 'skipped' ]] || exit 7;
[[ ${unitTests} == 'success' ]] || [[ ${unitTests} == 'skipped' ]]
|| exit 5;
exit 0;
diff --git a/CHANGES.md b/CHANGES.md
index 3af9896..6de747b 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -16,10 +16,13 @@ Release Notes.
* Add the duration field in the `trace list` command by @mrproliu in
https://github.com/apache/skywalking-cli/pull/225
* Remove the oldest `queryTraceFromColdStage` query call in the `trace list`
command by @mrproliu in https://github.com/apache/skywalking-cli/pull/225
* Add the sub-command `profiling pprof` for pprof query API by @JophieQu in
https://github.com/apache/skywalking-cli/pull/226
+* Add the `admin` command group for the OAP admin-server REST host (default
port `17128`), with a new global `--admin-url` flag (derived from `--base-url`
when unset). Covers every admin feature module: `admin preflight`; `admin
cluster nodes`, `admin config dump|ttl`, `admin alarm rules|rule` (status);
`admin inspect metrics|entities` (inspect); `admin ui-template
list|get|create|update|disable` (ui-management); `admin runtime-rule
list|bundled|get|add|inactivate|delete|dump` (runtime-r [...]
### Bug Fixes
* Fix wrong process id format by @mrproliu in
https://github.com/apache/skywalking-cli/pull/215
+* Migrate `alarm list` from the deprecated `getAlarm` GraphQL query to
`queryAlarms`, adding `--layer` and `--rules` filters (OAP 11.0.0).
+* `menu get` now reports a clear message when the OAP backend no longer serves
the UI menu (retired in OAP 11.0.0) instead of a raw GraphQL error.
0.14.0
------------------
diff --git a/README.md b/README.md
index f52b887..06afeb7 100644
--- a/README.md
+++ b/README.md
@@ -178,6 +178,7 @@ The compatibility table here only lists fully compatible
OAP versions, which mea
| \> = 0.12.0 | \> = 9.5.0 |
| \> = 0.13.0 | \> = 9.6.0 |
| \> = 0.14.0 | \> = 10.2.0 |
+| \> = 0.15.0 | \> = 11.0.0 |
# Contributing
diff --git a/assets/graphqls/alarm/alarms.graphql
b/assets/graphqls/alarm/alarms.graphql
index da2cbbe..1e4b477 100644
--- a/assets/graphqls/alarm/alarms.graphql
+++ b/assets/graphqls/alarm/alarms.graphql
@@ -15,8 +15,8 @@
# specific language governing permissions and limitations
# under the License.
-query ($duration: Duration!, $scope: Scope, $keyword: String, $paging:
Pagination!, $tags: [AlarmTag]) {
- result: getAlarm(duration: $duration, scope: $scope, keyword: $keyword,
paging: $paging, tags: $tags) {
+query ($condition: AlarmQueryCondition!) {
+ result: queryAlarms(condition: $condition) {
msgs {
startTime
scope
diff --git a/cmd/swctl/main.go b/cmd/swctl/main.go
index 6a29883..01ee2c9 100644
--- a/cmd/swctl/main.go
+++ b/cmd/swctl/main.go
@@ -22,6 +22,7 @@ import (
"os"
"runtime"
+ "github.com/apache/skywalking-cli/internal/commands/admin"
"github.com/apache/skywalking-cli/internal/commands/alarm"
"github.com/apache/skywalking-cli/internal/commands/browser"
"github.com/apache/skywalking-cli/internal/commands/completion"
@@ -115,6 +116,7 @@ services, service instances, etc.`
records.Command,
menu.Command,
hierarchy.Command,
+ admin.Command,
}
app.Before = interceptor.BeforeChain(
@@ -150,6 +152,15 @@ func flags() []cli.Flag {
Usage: "base `url` of the OAP backend graphql
service",
Value: "http://127.0.0.1:12800/graphql",
}),
+ altsrc.NewStringFlag(&cli.StringFlag{
+ Name: "admin-url",
+ Required: false,
+ EnvVars: []string{"SW_ADMIN_URL"},
+ Usage: "base `url` of the OAP admin-server REST service
(default port 17128), " +
+ "used by `swctl admin ...` sub-commands. If
empty, it is derived from `--base-url` " +
+ "by reusing its host with port 17128.",
+ Value: "",
+ }),
altsrc.NewStringFlag(&cli.StringFlag{
Name: "grpc-addr",
Usage: "backend gRPC service address `<host:port>`",
diff --git a/examples/.skywalking.yaml b/examples/.skywalking.yaml
index 55125f6..4adaf92 100644
--- a/examples/.skywalking.yaml
+++ b/examples/.skywalking.yaml
@@ -16,6 +16,9 @@
# under the License.
base-url: http://demo.skywalking.apache.org/graphql
+# admin-url is the OAP admin-server REST host used by `swctl admin ...`
sub-commands.
+# When omitted, it is derived from base-url's host with port 17128.
+admin-url: http://demo.skywalking.apache.org:17128
grpc-addr: 127.0.0.1:11800
username: basic-auth-username
password: basic-auth-password
diff --git a/internal/commands/admin/admin.go b/internal/commands/admin/admin.go
new file mode 100644
index 0000000..e77b042
--- /dev/null
+++ b/internal/commands/admin/admin.go
@@ -0,0 +1,59 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package admin
+
+import (
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/internal/commands/admin/alarm"
+ "github.com/apache/skywalking-cli/internal/commands/admin/cluster"
+ "github.com/apache/skywalking-cli/internal/commands/admin/config"
+ "github.com/apache/skywalking-cli/internal/commands/admin/dsldebug"
+ "github.com/apache/skywalking-cli/internal/commands/admin/inspect"
+ "github.com/apache/skywalking-cli/internal/commands/admin/oal"
+ "github.com/apache/skywalking-cli/internal/commands/admin/runtimerule"
+ "github.com/apache/skywalking-cli/internal/commands/admin/uitemplate"
+)
+
+// Command is the parent of every sub-command that talks to the OAP
admin-server
+// REST host (default port 17128), as opposed to the public GraphQL surface on
+// `--base-url` (default port 12800). The admin host bundles the status,
inspect,
+// ui-management, dsl-debugging and runtime-rule feature modules. Its address
comes
+// from `--admin-url` (or is derived from `--base-url` with port 17128).
+var Command = &cli.Command{
+ Name: "admin",
+ Usage: "Admin (REST) sub-commands that talk to the OAP admin-server
(default port 17128)",
+ UsageText: `Admin sub-commands call the OAP admin-server REST host, a
separate surface from the
+public GraphQL endpoint used by the other commands.
+
+The admin host address defaults to the "--base-url" host with port 17128;
override it
+with the global "--admin-url" flag (or the SW_ADMIN_URL env var / "admin-url"
config key).
+The admin host has no built-in authentication and is expected to sit behind a
gateway;
+"--username"/"--password"/"--authorization" and "--insecure" apply to it the
same way.`,
+ Subcommands: []*cli.Command{
+ preflightCommand,
+ cluster.Command,
+ config.Command,
+ alarm.Command,
+ inspect.Command,
+ uitemplate.Command,
+ runtimerule.Command,
+ dsldebug.Command,
+ oal.Command,
+ },
+}
diff --git a/internal/commands/admin/alarm/alarm.go
b/internal/commands/admin/alarm/alarm.go
new file mode 100644
index 0000000..fdf3ead
--- /dev/null
+++ b/internal/commands/admin/alarm/alarm.go
@@ -0,0 +1,98 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package alarm exposes the admin-server alarm runtime status (loaded rule
+// definitions and per-entity evaluation/window state). This is distinct from
the
+// top-level `swctl alarm` command, which reads fired alarm records via
GraphQL.
+package alarm
+
+import (
+ "fmt"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/pkg/admin/preflight"
+ "github.com/apache/skywalking-cli/pkg/admin/status"
+ "github.com/apache/skywalking-cli/pkg/display"
+ "github.com/apache/skywalking-cli/pkg/display/displayable"
+)
+
+var Command = &cli.Command{
+ Name: "alarm",
+ Usage: "Inspect alarm runtime status from the admin-server `status`
module",
+ UsageText: `Inspect the alarm-running kernel: loaded rule definitions
and per-entity
+evaluation/window state. This differs from "swctl alarm list", which returns
fired
+alarm records from the GraphQL surface.`,
+ Subcommands: []*cli.Command{
+ rulesCommand,
+ ruleCommand,
+ },
+}
+
+var rulesCommand = &cli.Command{
+ Name: "rules",
+ Usage: "List the loaded alarm rules per OAP node (GET
/status/alarm/rules)",
+ UsageText: `List the loaded alarm rules, fanned out across every OAP
node.
+
+Examples:
+1. List alarm rules:
+$ swctl admin alarm rules`,
+ Action: func(ctx *cli.Context) error {
+ rules, err := status.AlarmRules(ctx.Context)
+ if err != nil {
+ return preflight.Explain(ctx.Context, err,
preflight.ModuleStatus, "SW_STATUS")
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: rules})
+ },
+}
+
+var ruleCommand = &cli.Command{
+ Name: "rule",
+ Usage: "Show one alarm rule's definition and running state (GET
/status/alarm/{ruleId}[/{entityName}])",
+ ArgsUsage: "<ruleId> [<entityName>]",
+ UsageText: `Show the definition and running state of a single alarm
rule. When an
+entity name is given, the per-entity evaluation/window state is returned
instead.
+
+Examples:
+1. Show a rule's running state:
+$ swctl admin alarm rule service_resp_time_rule
+
+2. Show the per-entity state of a rule:
+$ swctl admin alarm rule service_resp_time_rule mock_b_service`,
+ Action: func(ctx *cli.Context) error {
+ args := ctx.Args()
+ ruleID := args.Get(0)
+ if ruleID == "" {
+ return fmt.Errorf("a <ruleId> argument is required")
+ }
+ entityName := args.Get(1)
+
+ var (
+ result *status.ClusterAlarmStatus
+ err error
+ )
+ if entityName == "" {
+ result, err = status.AlarmRule(ctx.Context, ruleID)
+ } else {
+ result, err = status.AlarmRuleEntity(ctx.Context,
ruleID, entityName)
+ }
+ if err != nil {
+ return preflight.Explain(ctx.Context, err,
preflight.ModuleStatus, "SW_STATUS")
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
diff --git a/internal/commands/menu/get.go
b/internal/commands/admin/cluster/cluster.go
similarity index 61%
copy from internal/commands/menu/get.go
copy to internal/commands/admin/cluster/cluster.go
index c7b9aac..26229fa 100644
--- a/internal/commands/menu/get.go
+++ b/internal/commands/admin/cluster/cluster.go
@@ -15,29 +15,38 @@
// specific language governing permissions and limitations
// under the License.
-package menu
+package cluster
import (
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/pkg/admin/preflight"
+ "github.com/apache/skywalking-cli/pkg/admin/status"
"github.com/apache/skywalking-cli/pkg/display"
"github.com/apache/skywalking-cli/pkg/display/displayable"
- "github.com/apache/skywalking-cli/pkg/graphql/menu"
-
- "github.com/urfave/cli/v2"
)
-var Get = &cli.Command{
- Name: "get",
- Usage: "Get the UI menu items",
- UsageText: `Get the UI menu items.
+var Command = &cli.Command{
+ Name: "cluster",
+ Usage: "Inspect the OAP cluster from the admin-server `status` module",
+ Subcommands: []*cli.Command{
+ nodesCommand,
+ },
+}
+
+var nodesCommand = &cli.Command{
+ Name: "nodes",
+ Usage: "List the OAP cluster peer nodes (GET /status/cluster/nodes)",
+ UsageText: `List the OAP cluster peer nodes as seen by the cluster
coordinator.
Examples:
-1. Get the UI menu items:
-$swctl menu get`,
+1. List cluster nodes:
+$ swctl admin cluster nodes`,
Action: func(ctx *cli.Context) error {
- menuItems, err := menu.GetItems(ctx.Context)
+ nodes, err := status.ClusterNodesQuery(ctx.Context)
if err != nil {
- return err
+ return preflight.Explain(ctx.Context, err,
preflight.ModuleStatus, "SW_STATUS")
}
- return display.Display(ctx.Context,
&displayable.Displayable{Data: menuItems})
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: nodes})
},
}
diff --git a/internal/commands/admin/config/config.go
b/internal/commands/admin/config/config.go
new file mode 100644
index 0000000..f41468f
--- /dev/null
+++ b/internal/commands/admin/config/config.go
@@ -0,0 +1,71 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package config
+
+import (
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/pkg/admin/preflight"
+ "github.com/apache/skywalking-cli/pkg/admin/status"
+ "github.com/apache/skywalking-cli/pkg/display"
+ "github.com/apache/skywalking-cli/pkg/display/displayable"
+)
+
+var Command = &cli.Command{
+ Name: "config",
+ Usage: "Inspect the OAP effective configuration and TTL from the
admin-server",
+ Subcommands: []*cli.Command{
+ dumpCommand,
+ ttlCommand,
+ },
+}
+
+var dumpCommand = &cli.Command{
+ Name: "dump",
+ Usage: "Dump the OAP node's effective, secrets-redacted configuration
(GET /debugging/config/dump)",
+ UsageText: `Dump the effective configuration of the OAP node as a flat
map of
+"<module>.<provider>.<property>" keys. Secrets are redacted by OAP.
+
+Examples:
+1. Dump the effective configuration:
+$ swctl admin config dump`,
+ Action: func(ctx *cli.Context) error {
+ dump, err := status.ConfigDump(ctx.Context)
+ if err != nil {
+ return preflight.Explain(ctx.Context, err,
preflight.ModuleStatus, "SW_STATUS")
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: dump})
+ },
+}
+
+var ttlCommand = &cli.Command{
+ Name: "ttl",
+ Usage: "Show the effective TTL configuration (GET /status/config/ttl)",
+ UsageText: `Show the effective metric / record / trace / log TTL bounds.
+
+Examples:
+1. Show the effective TTL configuration:
+$ swctl admin config ttl`,
+ Action: func(ctx *cli.Context) error {
+ ttl, err := status.ConfigTTL(ctx.Context)
+ if err != nil {
+ return preflight.Explain(ctx.Context, err,
preflight.ModuleStatus, "SW_STATUS")
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: ttl})
+ },
+}
diff --git a/internal/commands/admin/dsldebug/dsldebug.go
b/internal/commands/admin/dsldebug/dsldebug.go
new file mode 100644
index 0000000..2d04953
--- /dev/null
+++ b/internal/commands/admin/dsldebug/dsldebug.go
@@ -0,0 +1,185 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package dsldebug
+
+import (
+ "fmt"
+
+ "github.com/google/uuid"
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/pkg/admin/dsldebug"
+ "github.com/apache/skywalking-cli/pkg/admin/preflight"
+ "github.com/apache/skywalking-cli/pkg/display"
+ "github.com/apache/skywalking-cli/pkg/display/displayable"
+)
+
+var Command = &cli.Command{
+ Name: "dsl-debug",
+ Usage: "Live MAL / LAL / OAL debugger via the admin-server
`dsl-debugging` module",
+ UsageText: `Run sample-based debug sessions that capture how MAL / LAL
/ OAL rules transform
+live ingest, with per-stage captured records.`,
+ Subcommands: []*cli.Command{
+ statusCommand,
+ sessionsCommand,
+ sessionCommand,
+ },
+}
+
+var statusCommand = &cli.Command{
+ Name: "status",
+ Usage: "Show the dsl-debugging module health snapshot (GET
/dsl-debugging/status)",
+ Action: func(ctx *cli.Context) error {
+ result, err := dsldebug.Status(ctx.Context)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var sessionsCommand = &cli.Command{
+ Name: "sessions",
+ Usage: "List the active debug sessions (GET /dsl-debugging/sessions)",
+ Action: func(ctx *cli.Context) error {
+ result, err := dsldebug.ListSessions(ctx.Context)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var sessionCommand = &cli.Command{
+ Name: "session",
+ Usage: "Start / poll / stop a single debug session",
+ Subcommands: []*cli.Command{
+ sessionStartCommand,
+ sessionGetCommand,
+ sessionStopCommand,
+ },
+}
+
+var sessionStartCommand = &cli.Command{
+ Name: "start",
+ Usage: "Start a debug capture session (POST /dsl-debugging/session)",
+ UsageText: `Start a sample-based debug capture session.
+
+Examples:
+1. Debug a MAL metric:
+$ swctl admin dsl-debug session start --catalog otel-rules --name vm
--rule-name vm_memory_used`,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "catalog",
+ Usage: "session `catalog`: otel-rules /
log-mal-rules / telegraf-rules / lal / oal",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "name",
+ Usage: "rule file name (MAL/LAL) or OAL source class
`name`",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "rule-name",
+ Usage: "the metric / rule `name` within the file
(OAL: same as --name)",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "client-id",
+ Usage: "stable per-debug-context `id`; a random UUID is
generated when omitted",
+ },
+ &cli.StringFlag{
+ Name: "granularity",
+ Usage: "LAL capture `granularity`: block (default) or
statement",
+ },
+ &cli.IntFlag{
+ Name: "record-cap",
+ Usage: fmt.Sprintf("max records to capture before the
session is full (1-%d)", dsldebug.MaxRecordCap),
+ },
+ &cli.IntFlag{
+ Name: "retention-millis",
+ Usage: fmt.Sprintf("session wall-clock retention in ms
(max %d = 1h)", dsldebug.MaxRetentionMillis),
+ },
+ },
+ Action: func(ctx *cli.Context) error {
+ recordCap := ctx.Int("record-cap")
+ if recordCap < 0 || recordCap > dsldebug.MaxRecordCap {
+ return fmt.Errorf("--record-cap must be between 1 and
%d", dsldebug.MaxRecordCap)
+ }
+ retention := ctx.Int("retention-millis")
+ if retention < 0 || retention > dsldebug.MaxRetentionMillis {
+ return fmt.Errorf("--retention-millis must be between 1
and %d", dsldebug.MaxRetentionMillis)
+ }
+ clientID := ctx.String("client-id")
+ if clientID == "" {
+ clientID = uuid.New().String()
+ }
+
+ result, err := dsldebug.StartSession(ctx.Context,
&dsldebug.StartArgs{
+ ClientID: clientID,
+ Catalog: ctx.String("catalog"),
+ Name: ctx.String("name"),
+ RuleName: ctx.String("rule-name"),
+ Granularity: ctx.String("granularity"),
+ RecordCap: recordCap,
+ RetentionMillis: retention,
+ })
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var sessionGetCommand = &cli.Command{
+ Name: "get",
+ Usage: "Poll a session's captured records (GET
/dsl-debugging/session/{id})",
+ ArgsUsage: "<sessionId>",
+ Action: func(ctx *cli.Context) error {
+ id := ctx.Args().Get(0)
+ if id == "" {
+ return fmt.Errorf("a <sessionId> argument is required")
+ }
+ result, err := dsldebug.GetSession(ctx.Context, id)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var sessionStopCommand = &cli.Command{
+ Name: "stop",
+ Usage: "Stop a session (POST /dsl-debugging/session/{id}/stop)",
+ ArgsUsage: "<sessionId>",
+ Action: func(ctx *cli.Context) error {
+ id := ctx.Args().Get(0)
+ if id == "" {
+ return fmt.Errorf("a <sessionId> argument is required")
+ }
+ result, err := dsldebug.StopSession(ctx.Context, id)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+func explain(ctx *cli.Context, err error) error {
+ return preflight.Explain(ctx.Context, err, preflight.ModuleDSLDebug,
"SW_DSL_DEBUGGING")
+}
diff --git a/internal/commands/admin/inspect/inspect.go
b/internal/commands/admin/inspect/inspect.go
new file mode 100644
index 0000000..528d6c6
--- /dev/null
+++ b/internal/commands/admin/inspect/inspect.go
@@ -0,0 +1,132 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package inspect
+
+import (
+ "fmt"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/internal/commands/interceptor"
+ "github.com/apache/skywalking-cli/internal/flags"
+ "github.com/apache/skywalking-cli/internal/model"
+ "github.com/apache/skywalking-cli/pkg/admin/inspect"
+ "github.com/apache/skywalking-cli/pkg/admin/preflight"
+ "github.com/apache/skywalking-cli/pkg/display"
+ "github.com/apache/skywalking-cli/pkg/display/displayable"
+)
+
+var Command = &cli.Command{
+ Name: "inspect",
+ Usage: "Browse the metric catalog and entities from the admin-server
`inspect` module",
+ Subcommands: []*cli.Command{
+ metricsCommand,
+ entitiesCommand,
+ },
+}
+
+var metricsCommand = &cli.Command{
+ Name: "metrics",
+ Usage: "List the registered metric catalog (GET /inspect/metrics)",
+ UsageText: `List the registered metrics with their type, scope and
supported downsamplings.
+
+Examples:
+1. List every metric:
+$ swctl admin inspect metrics
+
+2. List service metrics that /inspect/entities accepts:
+$ swctl admin inspect metrics --catalog SERVICE --mqe-queryable`,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "regex",
+ Usage: "filter metric names by a Java `regex` (default
matches all)",
+ },
+ &cli.StringSliceFlag{
+ Name: "type",
+ Usage: "filter by metric `type` (REGULAR_VALUE /
LABELED_VALUE / HEATMAP / SAMPLED_RECORD); repeatable",
+ },
+ &cli.StringSliceFlag{
+ Name: "catalog",
+ Usage: "filter by `catalog` (SERVICE / SERVICE_INSTANCE
/ ENDPOINT / *_RELATION); repeatable",
+ },
+ &cli.BoolFlag{
+ Name: "mqe-queryable",
+ Usage: "return only metrics that /inspect/entities
accepts (REGULAR_VALUE, LABELED_VALUE)",
+ },
+ },
+ Action: func(ctx *cli.Context) error {
+ metrics, err := inspect.ListMetrics(ctx.Context,
inspect.MetricsOptions{
+ Regex: ctx.String("regex"),
+ Types: ctx.StringSlice("type"),
+ Catalogs: ctx.StringSlice("catalog"),
+ MQEQueryable: ctx.Bool("mqe-queryable"),
+ })
+ if err != nil {
+ return preflight.Explain(ctx.Context, err,
preflight.ModuleInspect, "SW_INSPECT")
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: metrics})
+ },
+}
+
+var entitiesCommand = &cli.Command{
+ Name: "entities",
+ Usage: "Enumerate the entities holding values for a metric (GET
/inspect/entities)",
+ UsageText: `Enumerate the entities currently holding values for a
metric over a time range.
+Each row carries an MQE-ready entity to paste into a follow-up "swctl metrics
exec"
+(execExpression) query. Only REGULAR_VALUE / LABELED_VALUE metrics are
accepted.
+
+Examples:
+1. Entities reporting service_cpm in the last 30 minutes:
+$ swctl admin inspect entities --metric service_cpm`,
+ Flags: flags.Flags(
+ flags.DurationFlags,
+ []cli.Flag{
+ &cli.StringFlag{
+ Name: "metric",
+ Usage: "the `metric` name to enumerate
entities for",
+ Required: true,
+ },
+ &cli.IntFlag{
+ Name: "limit",
+ Usage: fmt.Sprintf("max rows scanned at the
storage layer (1-%d, server default 300)", inspect.MaxLimit),
+ },
+ },
+ ),
+ Before: interceptor.BeforeChain(
+ interceptor.DurationInterceptor,
+ ),
+ Action: func(ctx *cli.Context) error {
+ limit := ctx.Int("limit")
+ if limit < 0 || limit > inspect.MaxLimit {
+ return fmt.Errorf("--limit must be between 1 and %d",
inspect.MaxLimit)
+ }
+ step := ctx.Generic("step").(*model.StepEnumValue).Selected
+
+ entities, err := inspect.ListEntities(ctx.Context,
inspect.EntitiesOptions{
+ Metric: ctx.String("metric"),
+ Start: ctx.String("start"),
+ End: ctx.String("end"),
+ Step: string(step),
+ Limit: limit,
+ })
+ if err != nil {
+ return preflight.Explain(ctx.Context, err,
preflight.ModuleInspect, "SW_INSPECT")
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: entities})
+ },
+}
diff --git a/internal/commands/admin/oal/oal.go
b/internal/commands/admin/oal/oal.go
new file mode 100644
index 0000000..45805fb
--- /dev/null
+++ b/internal/commands/admin/oal/oal.go
@@ -0,0 +1,105 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package oal
+
+import (
+ "fmt"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/pkg/admin/oal"
+ "github.com/apache/skywalking-cli/pkg/admin/preflight"
+ "github.com/apache/skywalking-cli/pkg/display"
+ "github.com/apache/skywalking-cli/pkg/display/displayable"
+)
+
+var Command = &cli.Command{
+ Name: "oal",
+ Usage: "Browse the read-only OAL catalog (the OAL debugger's rule
picker)",
+ UsageText: `Read-only listing of loaded OAL files and the
per-dispatcher source catalog used
+by the OAL live debugger. Hosted by the admin-server dsl-debugging module.`,
+ Subcommands: []*cli.Command{
+ filesCommand,
+ fileCommand,
+ rulesCommand,
+ ruleCommand,
+ },
+}
+
+var filesCommand = &cli.Command{
+ Name: "files",
+ Usage: "List the loaded .oal file names (GET /runtime/oal/files)",
+ Action: func(ctx *cli.Context) error {
+ result, err := oal.ListFiles(ctx.Context)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var fileCommand = &cli.Command{
+ Name: "file",
+ Usage: "Print the raw .oal text of one file (GET
/runtime/oal/files/{name})",
+ ArgsUsage: "<name>",
+ Action: func(ctx *cli.Context) error {
+ name := ctx.Args().Get(0)
+ if name == "" {
+ return fmt.Errorf("a <name> argument is required")
+ }
+ content, err := oal.GetFile(ctx.Context, name)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ fmt.Println(content)
+ return nil
+ },
+}
+
+var rulesCommand = &cli.Command{
+ Name: "rules",
+ Usage: "List the per-dispatcher OAL source catalog (GET
/runtime/oal/rules)",
+ Action: func(ctx *cli.Context) error {
+ result, err := oal.ListSources(ctx.Context)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var ruleCommand = &cli.Command{
+ Name: "rule",
+ Usage: "Show one OAL source's per-metric holder status (GET
/runtime/oal/rules/{source})",
+ ArgsUsage: "<source>",
+ Action: func(ctx *cli.Context) error {
+ source := ctx.Args().Get(0)
+ if source == "" {
+ return fmt.Errorf("a <source> argument is required")
+ }
+ result, err := oal.GetSource(ctx.Context, source)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+func explain(ctx *cli.Context, err error) error {
+ return preflight.Explain(ctx.Context, err, preflight.ModuleDSLDebug,
"SW_DSL_DEBUGGING")
+}
diff --git a/internal/commands/menu/get.go
b/internal/commands/admin/preflight.go
similarity index 56%
copy from internal/commands/menu/get.go
copy to internal/commands/admin/preflight.go
index c7b9aac..1eea7f1 100644
--- a/internal/commands/menu/get.go
+++ b/internal/commands/admin/preflight.go
@@ -15,29 +15,33 @@
// specific language governing permissions and limitations
// under the License.
-package menu
+package admin
import (
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/pkg/admin/preflight"
"github.com/apache/skywalking-cli/pkg/display"
"github.com/apache/skywalking-cli/pkg/display/displayable"
- "github.com/apache/skywalking-cli/pkg/graphql/menu"
-
- "github.com/urfave/cli/v2"
)
-var Get = &cli.Command{
- Name: "get",
- Usage: "Get the UI menu items",
- UsageText: `Get the UI menu items.
+var preflightCommand = &cli.Command{
+ Name: "preflight",
+ Usage: "Detect which admin feature modules are enabled on the OAP
admin-server",
+ UsageText: `Reads the effective configuration from the admin host and
reports which feature
+modules (status, inspect, ui-management, dsl-debugging, runtime-rule) are
enabled.
Examples:
-1. Get the UI menu items:
-$swctl menu get`,
+1. Check admin feature availability:
+$ swctl admin preflight`,
Action: func(ctx *cli.Context) error {
- menuItems, err := menu.GetItems(ctx.Context)
- if err != nil {
- return err
+ // Run reports per-module enablement; a transport error means
the admin host
+ // is unreachable, in which case we still print the
(all-disabled) result so
+ // the user sees which admin URL was probed.
+ result, err := preflight.Run(ctx.Context)
+ if err != nil && !result.AdminReachable {
+ return preflight.Explain(ctx.Context, err,
preflight.ModuleAdminServer, "SW_ADMIN_SERVER")
}
- return display.Display(ctx.Context,
&displayable.Displayable{Data: menuItems})
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
},
}
diff --git a/internal/commands/admin/runtimerule/runtimerule.go
b/internal/commands/admin/runtimerule/runtimerule.go
new file mode 100644
index 0000000..fc93803
--- /dev/null
+++ b/internal/commands/admin/runtimerule/runtimerule.go
@@ -0,0 +1,251 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package runtimerule
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/pkg/admin/preflight"
+ "github.com/apache/skywalking-cli/pkg/admin/runtimerule"
+ "github.com/apache/skywalking-cli/pkg/display"
+ "github.com/apache/skywalking-cli/pkg/display/displayable"
+ "github.com/apache/skywalking-cli/pkg/util"
+)
+
+var Command = &cli.Command{
+ Name: "runtime-rule",
+ Usage: "Hot-update MAL / LAL rules via the admin-server
`receiver-runtime-rule` module",
+ UsageText: `Add, override, inactivate and delete MAL / LAL rule files
at runtime without
+restarting OAP, and inspect the live and bundled rule state.
+
+Catalogs: otel-rules, log-mal-rules, telegraf-rules, lal.`,
+ Subcommands: []*cli.Command{
+ listCommand,
+ bundledCommand,
+ getCommand,
+ addCommand,
+ inactivateCommand,
+ deleteCommand,
+ dumpCommand,
+ },
+}
+
+func catalogFlag(required bool) cli.Flag {
+ return &cli.StringFlag{
+ Name: "catalog",
+ Usage: "rule `catalog`: otel-rules / log-mal-rules /
telegraf-rules / lal",
+ Required: required,
+ }
+}
+
+func nameFlag() cli.Flag {
+ return &cli.StringFlag{
+ Name: "name",
+ Usage: "rule `name`",
+ Required: true,
+ }
+}
+
+var listCommand = &cli.Command{
+ Name: "list",
+ Usage: "List the live rule state per node (GET /runtime/rule/list)",
+ Flags: []cli.Flag{catalogFlag(false)},
+ Action: func(ctx *cli.Context) error {
+ result, err := runtimerule.List(ctx.Context,
ctx.String("catalog"))
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var bundledCommand = &cli.Command{
+ Name: "bundled",
+ Usage: "List the static (bundled) rule twins for a catalog (GET
/runtime/rule/bundled)",
+ Flags: []cli.Flag{
+ catalogFlag(true),
+ &cli.BoolFlag{
+ Name: "with-content",
+ Usage: "include the bundled rule content",
+ Value: true,
+ },
+ },
+ Action: func(ctx *cli.Context) error {
+ result, err := runtimerule.ListBundled(ctx.Context,
ctx.String("catalog"), ctx.Bool("with-content"))
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var getCommand = &cli.Command{
+ Name: "get",
+ Usage: "Fetch a single rule's content + metadata (GET /runtime/rule)",
+ Flags: []cli.Flag{
+ catalogFlag(true),
+ nameFlag(),
+ &cli.StringFlag{
+ Name: "source",
+ Usage: "`source` to read: runtime (default, DAO first)
or bundled (static twin only)",
+ },
+ },
+ Action: func(ctx *cli.Context) error {
+ rule, err := runtimerule.Get(ctx.Context,
ctx.String("catalog"), ctx.String("name"), ctx.String("source"), "")
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: rule})
+ },
+}
+
+var addCommand = &cli.Command{
+ Name: "add",
+ Aliases: []string{"add-or-update"},
+ Usage: "Push a new or updated rule from a YAML file (POST
/runtime/rule/addOrUpdate)",
+ UsageText: `Push a new or updated MAL / LAL rule. The file holds the
raw rule YAML.
+
+Examples:
+1. Apply a MAL rule:
+$ swctl admin runtime-rule add --catalog otel-rules --name vm -f vm.yaml`,
+ Flags: []cli.Flag{
+ catalogFlag(true),
+ nameFlag(),
+ &cli.StringFlag{
+ Name: "file",
+ Aliases: []string{"f"},
+ Usage: "`path` to the rule YAML",
+ Required: true,
+ },
+ &cli.BoolFlag{
+ Name: "allow-storage-change",
+ Usage: "approve an edit that moves the rule's storage
identity (structural change)",
+ },
+ &cli.BoolFlag{
+ Name: "force",
+ Usage: "re-run apply even when the content is
byte-identical",
+ },
+ },
+ Action: func(ctx *cli.Context) error {
+ body, err := readFile(ctx.String("file"))
+ if err != nil {
+ return err
+ }
+ result, err := runtimerule.AddOrUpdate(ctx.Context,
ctx.String("catalog"), ctx.String("name"),
+ body, ctx.Bool("allow-storage-change"),
ctx.Bool("force"))
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var inactivateCommand = &cli.Command{
+ Name: "inactivate",
+ Usage: "Turn a rule off (POST /runtime/rule/inactivate)",
+ Flags: []cli.Flag{catalogFlag(true), nameFlag()},
+ Action: func(ctx *cli.Context) error {
+ result, err := runtimerule.Inactivate(ctx.Context,
ctx.String("catalog"), ctx.String("name"))
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var deleteCommand = &cli.Command{
+ Name: "delete",
+ Usage: "Remove a rule (POST /runtime/rule/delete)",
+ UsageText: `Remove a rule. An active rule must be inactivated first
(the server returns a
+409 requires_inactivate_first otherwise).
+
+Examples:
+1. Delete an inactivated rule:
+$ swctl admin runtime-rule delete --catalog otel-rules --name vm
+
+2. Revert a rule to its bundled twin:
+$ swctl admin runtime-rule delete --catalog otel-rules --name vm --mode
revertToBundled`,
+ Flags: []cli.Flag{
+ catalogFlag(true),
+ nameFlag(),
+ &cli.StringFlag{
+ Name: "mode",
+ Usage: "deletion `mode`; pass revertToBundled to
restore the static twin",
+ },
+ },
+ Action: func(ctx *cli.Context) error {
+ result, err := runtimerule.Delete(ctx.Context,
ctx.String("catalog"), ctx.String("name"), ctx.String("mode"))
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: result})
+ },
+}
+
+var dumpCommand = &cli.Command{
+ Name: "dump",
+ Usage: "Download a tar.gz snapshot of all rules (GET
/runtime/rule/dump)",
+ UsageText: `Download a tar.gz snapshot of all rules, or one catalog's.
+
+Examples:
+1. Dump every rule to a file:
+$ swctl admin runtime-rule dump -o rules.tar.gz
+
+2. Dump one catalog:
+$ swctl admin runtime-rule dump --catalog otel-rules -o otel.tar.gz`,
+ Flags: []cli.Flag{
+ catalogFlag(false),
+ &cli.StringFlag{
+ Name: "output",
+ Aliases: []string{"o"},
+ Usage: "`path` to write the tar.gz to (default:
stdout)",
+ Required: true,
+ },
+ },
+ Action: func(ctx *cli.Context) error {
+ data, err := runtimerule.Dump(ctx.Context,
ctx.String("catalog"))
+ if err != nil {
+ return explain(ctx, err)
+ }
+ out := util.ExpandFilePath(ctx.String("output"))
+ if err := os.WriteFile(out, data, 0o600); err != nil {
+ return fmt.Errorf("failed to write dump to %q: %w",
out, err)
+ }
+ fmt.Printf("wrote %d bytes to %s\n", len(data), out)
+ return nil
+ },
+}
+
+func readFile(path string) (string, error) {
+ content, err := os.ReadFile(util.ExpandFilePath(path))
+ if err != nil {
+ return "", fmt.Errorf("failed to read rule file %q: %w", path,
err)
+ }
+ // Send the rule bytes verbatim. The runtime-rule API hashes the raw
body for its
+ // contentHash / no-change detection, so the CLI must not normalize
whitespace —
+ // trimming or re-adding a trailing newline would make a byte-identical
rule look
+ // like a change.
+ return string(content), nil
+}
+
+func explain(ctx *cli.Context, err error) error {
+ return preflight.Explain(ctx.Context, err, preflight.ModuleRuntimeRule,
"SW_RECEIVER_RUNTIME_RULE")
+}
diff --git a/internal/commands/admin/uitemplate/uitemplate.go
b/internal/commands/admin/uitemplate/uitemplate.go
new file mode 100644
index 0000000..9db9fc1
--- /dev/null
+++ b/internal/commands/admin/uitemplate/uitemplate.go
@@ -0,0 +1,192 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package uitemplate
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/google/uuid"
+ "github.com/urfave/cli/v2"
+
+ "github.com/apache/skywalking-cli/pkg/admin/preflight"
+ "github.com/apache/skywalking-cli/pkg/admin/uitemplate"
+ "github.com/apache/skywalking-cli/pkg/display"
+ "github.com/apache/skywalking-cli/pkg/display/displayable"
+ "github.com/apache/skywalking-cli/pkg/util"
+)
+
+var Command = &cli.Command{
+ Name: "ui-template",
+ Usage: "Manage dashboard templates via the admin-server `ui-management`
module",
+ UsageText: `Manage OAP dashboard templates over REST. This replaces the
GraphQL
+UIConfigurationManagement template mutations retired in SkyWalking 11.0.0.
There is no
+delete; templates are soft-disabled.`,
+ Subcommands: []*cli.Command{
+ listCommand,
+ getCommand,
+ createCommand,
+ updateCommand,
+ disableCommand,
+ },
+}
+
+var listCommand = &cli.Command{
+ Name: "list",
+ Usage: "List all dashboard templates (GET /ui-management/templates)",
+ UsageText: `List all dashboard templates.
+
+Examples:
+1. List enabled templates:
+$ swctl admin ui-template list
+
+2. Include soft-disabled templates:
+$ swctl admin ui-template list --include-disabled`,
+ Flags: []cli.Flag{
+ &cli.BoolFlag{
+ Name: "include-disabled",
+ Usage: "also return soft-disabled templates",
+ },
+ },
+ Action: func(ctx *cli.Context) error {
+ templates, err := uitemplate.List(ctx.Context,
ctx.Bool("include-disabled"))
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: templates})
+ },
+}
+
+var getCommand = &cli.Command{
+ Name: "get",
+ Usage: "Get a single dashboard template by ID (GET
/ui-management/templates/{id})",
+ ArgsUsage: "<id>",
+ Action: func(ctx *cli.Context) error {
+ id := ctx.Args().Get(0)
+ if id == "" {
+ return fmt.Errorf("an <id> argument is required")
+ }
+ template, err := uitemplate.Get(ctx.Context, id)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: template})
+ },
+}
+
+var createCommand = &cli.Command{
+ Name: "create",
+ Usage: "Add a new dashboard template (POST /ui-management/templates)",
+ UsageText: `Add a new dashboard template. The file holds the
JSON-encoded template
+configuration. An id is required by OAP; a random UUID is generated when --id
is omitted.
+
+Examples:
+1. Create a template from a file:
+$ swctl admin ui-template create -f my-dashboard.json`,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "file",
+ Aliases: []string{"f"},
+ Usage: "`path` to the JSON-encoded template
configuration",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "id",
+ Usage: "template `id`; a random UUID is generated when
omitted",
+ },
+ },
+ Action: func(ctx *cli.Context) error {
+ configuration, err := readFile(ctx.String("file"))
+ if err != nil {
+ return err
+ }
+ id := ctx.String("id")
+ if id == "" {
+ id = uuid.New().String()
+ }
+ status, err := uitemplate.Create(ctx.Context, id, configuration)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: status})
+ },
+}
+
+var updateCommand = &cli.Command{
+ Name: "update",
+ Usage: "Update an existing dashboard template (PUT
/ui-management/templates)",
+ UsageText: `Update an existing dashboard template by ID with a new
configuration.
+
+Examples:
+1. Update a template:
+$ swctl admin ui-template update --id <uuid> -f my-dashboard.json`,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "id",
+ Usage: "`id` of the template to update",
+ Required: true,
+ },
+ &cli.StringFlag{
+ Name: "file",
+ Aliases: []string{"f"},
+ Usage: "`path` to the JSON-encoded template
configuration",
+ Required: true,
+ },
+ },
+ Action: func(ctx *cli.Context) error {
+ configuration, err := readFile(ctx.String("file"))
+ if err != nil {
+ return err
+ }
+ status, err := uitemplate.Update(ctx.Context, ctx.String("id"),
configuration)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: status})
+ },
+}
+
+var disableCommand = &cli.Command{
+ Name: "disable",
+ Usage: "Soft-disable a dashboard template (POST
/ui-management/templates/{id}/disable)",
+ ArgsUsage: "<id>",
+ Action: func(ctx *cli.Context) error {
+ id := ctx.Args().Get(0)
+ if id == "" {
+ return fmt.Errorf("an <id> argument is required")
+ }
+ status, err := uitemplate.Disable(ctx.Context, id)
+ if err != nil {
+ return explain(ctx, err)
+ }
+ return display.Display(ctx.Context,
&displayable.Displayable{Data: status})
+ },
+}
+
+func readFile(path string) (string, error) {
+ content, err := os.ReadFile(util.ExpandFilePath(path))
+ if err != nil {
+ return "", fmt.Errorf("failed to read template file %q: %w",
path, err)
+ }
+ return strings.TrimSpace(string(content)), nil
+}
+
+func explain(ctx *cli.Context, err error) error {
+ return preflight.Explain(ctx.Context, err, preflight.ModuleUIManage,
"SW_UI_MANAGEMENT")
+}
diff --git a/internal/commands/alarm/list.go b/internal/commands/alarm/list.go
index beae908..5430630 100644
--- a/internal/commands/alarm/list.go
+++ b/internal/commands/alarm/list.go
@@ -29,6 +29,7 @@ import (
"github.com/apache/skywalking-cli/pkg/display"
"github.com/apache/skywalking-cli/pkg/display/displayable"
"github.com/apache/skywalking-cli/pkg/graphql/alarm"
+ "github.com/apache/skywalking-cli/pkg/logger"
api "skywalking.apache.org/repo/goapi/query"
)
@@ -58,9 +59,19 @@ $ swctl alarm list
Usage: "`tags` of the alarm, in form of
`key=value,key=value`",
Required: false,
},
+ &cli.StringFlag{
+ Name: "layer",
+ Usage: "filter alarms by the underlying
entity `layer`, e.g. GENERAL, MESH",
+ Required: false,
+ },
+ &cli.StringFlag{
+ Name: "rules",
+ Usage: "filter alarms by the alarm `rule`
name(s) that fired them, comma-separated",
+ Required: false,
+ },
&cli.GenericFlag{
Name: "scope",
- Usage: "the `scope` of the alarm entity",
+ Usage: "(deprecated) the `scope` of the alarm
entity; ignored, queryAlarms filters by entity/layer/rule instead",
Value: &model.ScopeEnumValue{
Enum: api.AllScope,
Default: "",
@@ -80,7 +91,21 @@ $ swctl alarm list
keyword := ctx.String("keyword")
tagStr := ctx.String("tags")
- scope := ctx.Generic("scope").(*model.ScopeEnumValue).Selected
+ layer := ctx.String("layer")
+
+ if ctx.IsSet("scope") {
+ logger.Log.Warn("--scope is deprecated and ignored: the
queryAlarms API filters by entity/layer/rule, " +
+ "not by a bare scope. Use --layer and --rules
instead.")
+ }
+
+ var ruleNames []string
+ if rules := ctx.String("rules"); rules != "" {
+ for rule := range strings.SplitSeq(rules, ",") {
+ if r := strings.TrimSpace(rule); r != "" {
+ ruleNames = append(ruleNames, r)
+ }
+ }
+ }
duration := api.Duration{
Start: start,
@@ -108,11 +133,12 @@ $ swctl alarm list
}
condition := &alarm.ListAlarmCondition{
- Duration: &duration,
- Keyword: keyword,
- Scope: scope,
- Tags: tags,
- Paging: &paging,
+ Duration: &duration,
+ Keyword: keyword,
+ Tags: tags,
+ Paging: &paging,
+ Layer: layer,
+ RuleNames: ruleNames,
}
alarms, err := alarm.Alarms(ctx.Context, condition)
if err != nil {
diff --git a/internal/commands/interceptor/interceptor.go
b/internal/commands/interceptor/interceptor.go
index 9cb873e..32bbf0b 100644
--- a/internal/commands/interceptor/interceptor.go
+++ b/internal/commands/interceptor/interceptor.go
@@ -20,6 +20,7 @@ package interceptor
import (
"context"
+ adminclient "github.com/apache/skywalking-cli/pkg/admin/client"
"github.com/apache/skywalking-cli/pkg/contextkey"
"github.com/urfave/cli/v2"
@@ -30,6 +31,8 @@ func BeforeChain(beforeFunctions ...cli.BeforeFunc)
cli.BeforeFunc {
return func(cliCtx *cli.Context) error {
ctx := cliCtx.Context
ctx = context.WithValue(ctx, contextkey.BaseURL{},
cliCtx.String("base-url"))
+ ctx = context.WithValue(ctx, contextkey.AdminURL{},
+ adminclient.DeriveAdminURL(cliCtx.String("base-url"),
cliCtx.String("admin-url")))
ctx = context.WithValue(ctx, contextkey.Insecure{},
cliCtx.Bool("insecure"))
ctx = context.WithValue(ctx, contextkey.Username{},
cliCtx.String("username"))
ctx = context.WithValue(ctx, contextkey.Password{},
cliCtx.String("password"))
diff --git a/internal/commands/menu/get.go b/internal/commands/menu/get.go
index c7b9aac..d41ae1f 100644
--- a/internal/commands/menu/get.go
+++ b/internal/commands/menu/get.go
@@ -18,6 +18,9 @@
package menu
import (
+ "fmt"
+ "strings"
+
"github.com/apache/skywalking-cli/pkg/display"
"github.com/apache/skywalking-cli/pkg/display/displayable"
"github.com/apache/skywalking-cli/pkg/graphql/menu"
@@ -36,8 +39,35 @@ $swctl menu get`,
Action: func(ctx *cli.Context) error {
menuItems, err := menu.GetItems(ctx.Context)
if err != nil {
+ if isMenuUnsupported(err) {
+ return fmt.Errorf("this OAP version no longer
serves the UI menu: the `getMenuItems` " +
+ "GraphQL query was retired in
SkyWalking 11.0.0, where the OAP backend stopped storing " +
+ "and serving the sidebar menu. The menu
is now owned client-side by Horizon UI " +
+
"(https://github.com/apache/skywalking-horizon-ui). Upgrade/downgrade swctl to
match your " +
+ "OAP version, or stop using `swctl menu
get` against 11.0.0+ backends")
+ }
return err
}
return display.Display(ctx.Context,
&displayable.Displayable{Data: menuItems})
},
}
+
+// isMenuUnsupported reports whether err is the GraphQL schema-validation
error raised
+// by an OAP backend that no longer defines the retired `getMenuItems` query
(11.0.0+),
+// as opposed to a transport or other runtime error. graphql-java phrases this
as a
+// "FieldUndefined" validation error; we match defensively on the field name
plus a
+// validation marker so wording changes do not break detection.
+func isMenuUnsupported(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ if !strings.Contains(msg, "getMenuItems") {
+ return false
+ }
+ lower := strings.ToLower(msg)
+ return strings.Contains(lower, "fieldundefined") ||
+ strings.Contains(lower, "undefined") ||
+ strings.Contains(lower, "cannot query field") ||
+ strings.Contains(lower, "validation error")
+}
diff --git a/pkg/admin/client/client.go b/pkg/admin/client/client.go
new file mode 100644
index 0000000..ec431ac
--- /dev/null
+++ b/pkg/admin/client/client.go
@@ -0,0 +1,238 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package client is the REST client for the OAP admin-server (default port
17128),
+// the HTTP host that bundles the status, inspect, ui-management,
dsl-debugging and
+// runtime-rule feature modules. It is the REST counterpart of
pkg/graphql/client
+// and shares its TLS / basic-auth handling via pkg/transport.
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/apache/skywalking-cli/pkg/contextkey"
+ "github.com/apache/skywalking-cli/pkg/transport"
+)
+
+const (
+ // DefaultAdminURL is the fallback admin-server REST base URL.
+ DefaultAdminURL = "http://127.0.0.1:17128"
+ // DefaultAdminPort is the admin-server REST port (admin-server `port`).
+ DefaultAdminPort = "17128"
+
+ defaultTimeout = 30 * time.Second
+
+ contentTypeJSON = "application/json"
+)
+
+// DeriveAdminURL resolves the admin REST base URL. When adminURL is non-empty
it is
+// used verbatim (whitespace and trailing slash trimmed). Otherwise the admin
URL is
+// derived from the GraphQL base URL by reusing its scheme and host and
swapping in the
+// admin port, dropping any path such as `/graphql`. A non-parseable base URL
falls
+// back to DefaultAdminURL.
+func DeriveAdminURL(baseURL, adminURL string) string {
+ if s := strings.TrimRight(strings.TrimSpace(adminURL), "/"); s != "" {
+ return s
+ }
+ u, err := url.Parse(strings.TrimSpace(baseURL))
+ if err != nil || u.Hostname() == "" {
+ return DefaultAdminURL
+ }
+ scheme := u.Scheme
+ if scheme == "" {
+ scheme = "http"
+ }
+ // net.JoinHostPort brackets IPv6 hosts, e.g. http://[::1]:17128.
+ return scheme + "://" + net.JoinHostPort(u.Hostname(), DefaultAdminPort)
+}
+
+// BaseURL returns the admin REST base URL stored in the context (set by the
+// interceptor), trailing slash trimmed, falling back to DefaultAdminURL.
+func BaseURL(ctx context.Context) string {
+ return strings.TrimRight(transport.GetValue(ctx, contextkey.AdminURL{},
DefaultAdminURL), "/")
+}
+
+// Request describes a single admin REST call.
+type Request struct {
+ Method string
+ Path string // appended to the admin base URL, e.g.
"/status/cluster/nodes"
+ Query url.Values
+ Body io.Reader
+ ContentType string // request Content-Type, when a body is
sent
+ Accept string // Accept header; defaults to
application/json
+ Headers map[string]string // extra request headers (e.g.
If-None-Match)
+}
+
+// Response is the raw result of an admin REST call, exposed so callers can
read
+// status-dependent headers (e.g. runtime-rule X-Sw-* / ETag) and bodies
(tar.gz).
+type Response struct {
+ StatusCode int
+ Header http.Header
+ Body []byte
+ URL string
+}
+
+// IsSuccess reports whether the response carries a 2xx status code.
+func (r *Response) IsSuccess() bool {
+ return r.StatusCode >= 200 && r.StatusCode < 300
+}
+
+// APIError is returned for non-2xx admin responses. It decodes the common
admin
+// error envelopes so callers can switch on the semantic Status code (e.g.
+// requires_inactivate_first, session_not_found, cluster_view_split).
+type APIError struct {
+ StatusCode int
+ URL string
+ Status string // applyStatus / code / status from the JSON envelope
+ Message string
+ Body string
+}
+
+func (e *APIError) Error() string {
+ msg := e.Message
+ if msg == "" {
+ msg = e.Body
+ }
+ if e.Status != "" {
+ return fmt.Sprintf("admin API %s: HTTP %d (%s): %s", e.URL,
e.StatusCode, e.Status, msg)
+ }
+ return fmt.Sprintf("admin API %s: HTTP %d: %s", e.URL, e.StatusCode,
msg)
+}
+
+// ParseError builds an APIError from a non-2xx response, decoding the admin
error
+// envelopes: {applyStatus,message} (runtime-rule), {status,code,message}
+// (dsl-debugging / oal) and {error} (inspect). A non-JSON body is kept as raw
text.
+func ParseError(resp *Response) *APIError {
+ e := &APIError{StatusCode: resp.StatusCode, URL: resp.URL, Body:
strings.TrimSpace(string(resp.Body))}
+ var env struct {
+ ApplyStatus string `json:"applyStatus"`
+ Status string `json:"status"`
+ Code string `json:"code"`
+ Message string `json:"message"`
+ Error string `json:"error"`
+ }
+ if json.Unmarshal(resp.Body, &env) == nil {
+ switch {
+ case env.ApplyStatus != "":
+ e.Status = env.ApplyStatus
+ case env.Code != "":
+ e.Status = env.Code
+ case env.Status != "":
+ e.Status = env.Status
+ }
+ switch {
+ case env.Message != "":
+ e.Message = env.Message
+ case env.Error != "":
+ e.Message = env.Error
+ }
+ }
+ return e
+}
+
+// Do performs req against the admin base URL and returns the raw Response for
any
+// HTTP status. Only transport-level failures are returned as an error; callers
+// decide how to treat non-2xx (see Response.IsSuccess / ParseError). It
reuses the
+// shared TLS and basic-auth handling from pkg/transport.
+func Do(ctx context.Context, req *Request) (*Response, error) {
+ full := BaseURL(ctx) + req.Path
+ if len(req.Query) > 0 {
+ full += "?" + req.Query.Encode()
+ }
+
+ httpReq, err := http.NewRequestWithContext(ctx, req.Method, full,
req.Body)
+ if err != nil {
+ return nil, err
+ }
+ accept := req.Accept
+ if accept == "" {
+ accept = contentTypeJSON
+ }
+ httpReq.Header.Set("Accept", accept)
+ if req.ContentType != "" {
+ httpReq.Header.Set("Content-Type", req.ContentType)
+ }
+ if auth := transport.AuthHeader(ctx); auth != "" {
+ httpReq.Header.Set("Authorization", auth)
+ }
+ for k, v := range req.Headers {
+ httpReq.Header.Set(k, v)
+ }
+
+ httpClient := transport.HTTPClient(ctx)
+ httpClient.Timeout = defaultTimeout
+ resp, err := httpClient.Do(httpReq)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ return &Response{StatusCode: resp.StatusCode, Header: resp.Header,
Body: body, URL: full}, nil
+}
+
+// GetJSON issues a GET to path and decodes a JSON 2xx response into out
(which may be
+// nil to discard the body). A non-2xx response is returned as an *APIError.
+func GetJSON(ctx context.Context, path string, query url.Values, out any)
error {
+ resp, err := Do(ctx, &Request{Method: http.MethodGet, Path: path,
Query: query})
+ if err != nil {
+ return err
+ }
+ if !resp.IsSuccess() {
+ return ParseError(resp)
+ }
+ if out == nil || len(resp.Body) == 0 {
+ return nil
+ }
+ return json.Unmarshal(resp.Body, out)
+}
+
+// SendJSON issues method to path with an optional JSON body and decodes a
JSON 2xx
+// response into out (which may be nil). A non-2xx response is returned as an
*APIError.
+func SendJSON(ctx context.Context, method, path string, query url.Values, in,
out any) error {
+ req := &Request{Method: method, Path: path, Query: query}
+ if in != nil {
+ data, err := json.Marshal(in)
+ if err != nil {
+ return err
+ }
+ req.Body = strings.NewReader(string(data))
+ req.ContentType = contentTypeJSON
+ }
+ resp, err := Do(ctx, req)
+ if err != nil {
+ return err
+ }
+ if !resp.IsSuccess() {
+ return ParseError(resp)
+ }
+ if out == nil || len(resp.Body) == 0 {
+ return nil
+ }
+ return json.Unmarshal(resp.Body, out)
+}
diff --git a/pkg/admin/client/client_test.go b/pkg/admin/client/client_test.go
new file mode 100644
index 0000000..006b93f
--- /dev/null
+++ b/pkg/admin/client/client_test.go
@@ -0,0 +1,79 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package client
+
+import "testing"
+
+func TestDeriveAdminURL(t *testing.T) {
+ tests := []struct {
+ name string
+ baseURL string
+ adminURL string
+ want string
+ }{
+ {
+ name: "derive from default graphql base-url",
+ baseURL: "http://127.0.0.1:12800/graphql",
+ want: "http://127.0.0.1:17128",
+ },
+ {
+ name: "derive keeps the base host",
+ baseURL: "http://1.2.3.4:12800/graphql",
+ want: "http://1.2.3.4:17128",
+ },
+ {
+ name: "derive preserves https scheme",
+ baseURL: "https://oap.example.com:12800/graphql",
+ want: "https://oap.example.com:17128",
+ },
+ {
+ name: "derive brackets an IPv6 host",
+ baseURL: "http://[::1]:12800/graphql",
+ want: "http://[::1]:17128",
+ },
+ {
+ name: "explicit admin-url wins and trailing slash
trimmed",
+ baseURL: "http://1.2.3.4:12800/graphql",
+ adminURL: "http://admin.example.com:17128/",
+ want: "http://admin.example.com:17128",
+ },
+ {
+ name: "explicit admin-url with whitespace",
+ baseURL: "http://1.2.3.4:12800/graphql",
+ adminURL: " http://admin:17128 ",
+ want: "http://admin:17128",
+ },
+ {
+ name: "empty base-url falls back to default admin
url",
+ baseURL: "",
+ want: DefaultAdminURL,
+ },
+ {
+ name: "host without scheme defaults to http",
+ baseURL: "//demo.skywalking.apache.org:12800/graphql",
+ want: "http://demo.skywalking.apache.org:17128",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := DeriveAdminURL(tt.baseURL, tt.adminURL); got
!= tt.want {
+ t.Errorf("DeriveAdminURL(%q, %q) = %q, want
%q", tt.baseURL, tt.adminURL, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/pkg/admin/dsldebug/dsldebug.go b/pkg/admin/dsldebug/dsldebug.go
new file mode 100644
index 0000000..a87fb52
--- /dev/null
+++ b/pkg/admin/dsldebug/dsldebug.go
@@ -0,0 +1,110 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package dsldebug wraps the OAP admin-server `dsl-debugging` feature module:
a
+// sample-based live debugger for MAL / LAL / OAL rules. The deeply-nested
per-stage
+// capture payloads are passed through as generic JSON for display; only the
request
+// parameters and client-side limits are modeled.
+package dsldebug
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+
+ "github.com/apache/skywalking-cli/pkg/admin/client"
+)
+
+const (
+ // MaxRecordCap mirrors OAP's SessionLimits.MAX_RECORD_CAP.
+ MaxRecordCap = 100
+ // MaxRetentionMillis mirrors OAP's SessionLimits.MAX_RETENTION_MILLIS
(1 hour).
+ MaxRetentionMillis = 60 * 60 * 1000
+)
+
+// Catalogs accepted by a debug session.
+var Catalogs = []string{"otel-rules", "log-mal-rules", "telegraf-rules",
"lal", "oal"}
+
+// StartArgs holds the inputs of POST /dsl-debugging/session. Catalog, Name,
RuleName
+// and ClientID are mandatory query params; RecordCap / RetentionMillis are
optional and
+// sent as a JSON body only when set. Granularity (LAL only) is sent as a
query param.
+type StartArgs struct {
+ ClientID string
+ Catalog string
+ Name string
+ RuleName string
+ Granularity string
+ RecordCap int
+ RetentionMillis int
+}
+
+// StartSession opens a debug capture session.
+func StartSession(ctx context.Context, a *StartArgs) (any, error) {
+ query := url.Values{
+ "catalog": []string{a.Catalog},
+ "name": []string{a.Name},
+ "ruleName": []string{a.RuleName},
+ "clientId": []string{a.ClientID},
+ }
+ if a.Granularity != "" {
+ query.Set("granularity", a.Granularity)
+ }
+
+ body := map[string]any{}
+ if a.RecordCap > 0 {
+ body["recordCap"] = a.RecordCap
+ }
+ if a.RetentionMillis > 0 {
+ body["retentionMillis"] = a.RetentionMillis
+ }
+ var in any
+ if len(body) > 0 {
+ in = body
+ }
+
+ var out any
+ err := client.SendJSON(ctx, http.MethodPost, "/dsl-debugging/session",
query, in, &out)
+ return out, err
+}
+
+// GetSession polls a session's captured records (GET
/dsl-debugging/session/{id}).
+func GetSession(ctx context.Context, id string) (any, error) {
+ var out any
+ err := client.GetJSON(ctx,
"/dsl-debugging/session/"+url.PathEscape(id), nil, &out)
+ return out, err
+}
+
+// StopSession stops a session (POST /dsl-debugging/session/{id}/stop).
Idempotent.
+func StopSession(ctx context.Context, id string) (any, error) {
+ var out any
+ err := client.SendJSON(ctx, http.MethodPost,
"/dsl-debugging/session/"+url.PathEscape(id)+"/stop", nil, nil, &out)
+ return out, err
+}
+
+// ListSessions lists the active sessions (GET /dsl-debugging/sessions).
+func ListSessions(ctx context.Context) (any, error) {
+ var out any
+ err := client.GetJSON(ctx, "/dsl-debugging/sessions", nil, &out)
+ return out, err
+}
+
+// Status returns the module health snapshot (GET /dsl-debugging/status).
+func Status(ctx context.Context) (any, error) {
+ var out any
+ err := client.GetJSON(ctx, "/dsl-debugging/status", nil, &out)
+ return out, err
+}
diff --git a/pkg/admin/inspect/inspect.go b/pkg/admin/inspect/inspect.go
new file mode 100644
index 0000000..6ff9807
--- /dev/null
+++ b/pkg/admin/inspect/inspect.go
@@ -0,0 +1,123 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package inspect wraps the OAP admin-server `inspect` feature module: browse
the
+// metric catalog and enumerate the entities currently holding values for a
metric.
+// The entity rows carry an MQE-ready payload that pastes into a follow-up
+// execExpression query on the public GraphQL surface.
+package inspect
+
+import (
+ "context"
+ "net/url"
+ "strconv"
+
+ "github.com/apache/skywalking-cli/pkg/admin/client"
+)
+
+// MaxLimit is the server-side hard cap on /inspect/entities rows.
+const MaxLimit = 300
+
+// Metric is a single entry of the metric catalog.
+type Metric struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Catalog string `json:"catalog"`
+ ScopeID int `json:"scopeId"`
+ Scope string `json:"scope"`
+ ValueColumnName string `json:"valueColumnName"`
+ Downsamplings []string `json:"downsamplings"`
+}
+
+// Metrics is the response of GET /inspect/metrics.
+type Metrics struct {
+ Metrics []Metric `json:"metrics"`
+}
+
+// MetricsOptions holds the optional filters of GET /inspect/metrics.
+type MetricsOptions struct {
+ Regex string
+ Types []string
+ Catalogs []string
+ MQEQueryable bool
+}
+
+// Entity is one row of GET /inspect/entities: the decoded entity plus an
MQE-ready
+// payload to feed back into execExpression.
+type Entity struct {
+ EntityID string `json:"entityId"`
+ Decoded any `json:"decoded"`
+ Layer string `json:"layer"`
+ MqeEntity any `json:"mqeEntity"`
+}
+
+// Entities is the response of GET /inspect/entities.
+type Entities struct {
+ Metric string `json:"metric"`
+ Scope string `json:"scope"`
+ Step string `json:"step"`
+ Start string `json:"start"`
+ End string `json:"end"`
+ Rows []Entity `json:"rows"`
+}
+
+// EntitiesOptions holds the parameters of GET /inspect/entities.
+type EntitiesOptions struct {
+ Metric string
+ Start string
+ End string
+ Step string
+ Limit int
+}
+
+// ListMetrics lists the registered metric catalog (GET /inspect/metrics).
+func ListMetrics(ctx context.Context, opts MetricsOptions) (*Metrics, error) {
+ query := url.Values{}
+ if opts.Regex != "" {
+ query.Set("regex", opts.Regex)
+ }
+ for _, t := range opts.Types {
+ query.Add("type", t)
+ }
+ for _, c := range opts.Catalogs {
+ query.Add("catalog", c)
+ }
+ if opts.MQEQueryable {
+ query.Set("mqeQueryable", "true")
+ }
+
+ var out Metrics
+ err := client.GetJSON(ctx, "/inspect/metrics", query, &out)
+ return &out, err
+}
+
+// ListEntities enumerates the entities holding values for a metric over a
time range
+// (GET /inspect/entities). Only REGULAR_VALUE / LABELED_VALUE metrics are
accepted.
+func ListEntities(ctx context.Context, opts EntitiesOptions) (*Entities,
error) {
+ query := url.Values{}
+ query.Set("metric", opts.Metric)
+ query.Set("start", opts.Start)
+ query.Set("end", opts.End)
+ query.Set("step", opts.Step)
+ if opts.Limit > 0 {
+ query.Set("limit", strconv.Itoa(opts.Limit))
+ }
+
+ var out Entities
+ err := client.GetJSON(ctx, "/inspect/entities", query, &out)
+ return &out, err
+}
diff --git a/pkg/admin/oal/oal.go b/pkg/admin/oal/oal.go
new file mode 100644
index 0000000..ad9c3e9
--- /dev/null
+++ b/pkg/admin/oal/oal.go
@@ -0,0 +1,67 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package oal wraps the read-only OAL listing endpoints (`/runtime/oal/*`)
hosted by
+// the OAP admin-server `dsl-debugging` feature module: the rule-picker source
for the
+// OAL live debugger. OAL hot-update is upstream-deferred; these endpoints are
read-only.
+package oal
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+
+ "github.com/apache/skywalking-cli/pkg/admin/client"
+)
+
+// ListFiles lists the loaded .oal file names (GET /runtime/oal/files).
+func ListFiles(ctx context.Context) (any, error) {
+ var out any
+ err := client.GetJSON(ctx, "/runtime/oal/files", nil, &out)
+ return out, err
+}
+
+// GetFile returns the raw .oal text of a single file (GET
/runtime/oal/files/{name}).
+func GetFile(ctx context.Context, name string) (string, error) {
+ resp, err := client.Do(ctx, &client.Request{
+ Method: http.MethodGet,
+ Path: "/runtime/oal/files/" + url.PathEscape(name),
+ Accept: "application/json, text/plain",
+ })
+ if err != nil {
+ return "", err
+ }
+ if !resp.IsSuccess() {
+ return "", client.ParseError(resp)
+ }
+ return string(resp.Body), nil
+}
+
+// ListSources lists the per-dispatcher OAL source catalog (GET
/runtime/oal/rules).
+func ListSources(ctx context.Context) (any, error) {
+ var out any
+ err := client.GetJSON(ctx, "/runtime/oal/rules", nil, &out)
+ return out, err
+}
+
+// GetSource returns one source's per-metric holder status
+// (GET /runtime/oal/rules/{source}).
+func GetSource(ctx context.Context, source string) (any, error) {
+ var out any
+ err := client.GetJSON(ctx,
"/runtime/oal/rules/"+url.PathEscape(source), nil, &out)
+ return out, err
+}
diff --git a/pkg/admin/preflight/preflight.go b/pkg/admin/preflight/preflight.go
new file mode 100644
index 0000000..963d7db
--- /dev/null
+++ b/pkg/admin/preflight/preflight.go
@@ -0,0 +1,121 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package preflight performs admin-server feature detection by reading the
effective
+// configuration dump, mirroring how Horizon UI degrades gracefully when an
admin
+// feature module is disabled or the admin host is unreachable.
+package preflight
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/apache/skywalking-cli/pkg/admin/client"
+)
+
+// Known admin feature-module selector keys (the first dotted segment of a
config-dump
+// key) and the environment variables that enable them.
+const (
+ ModuleAdminServer = "admin-server"
+ ModuleStatus = "status"
+ ModuleInspect = "inspect"
+ ModuleUIManage = "ui-management"
+ ModuleDSLDebug = "dsl-debugging"
+ ModuleRuntimeRule = "receiver-runtime-rule"
+)
+
+// Module describes one admin feature module and how to enable it.
+type Module struct {
+ Name string `json:"name"`
+ EnvVar string `json:"envVar"`
+ Enabled bool `json:"enabled"`
+}
+
+// Result is the outcome of a preflight check against the admin host.
+type Result struct {
+ AdminURL string `json:"adminURL"`
+ AdminReachable bool `json:"adminReachable"`
+ Modules []Module `json:"modules"`
+
+ enabled map[string]bool
+}
+
+// IsEnabled reports whether a feature module (by selector key) is enabled.
+func (r *Result) IsEnabled(module string) bool { return r.enabled[module] }
+
+var knownModules = []struct{ name, env string }{
+ {ModuleAdminServer, "SW_ADMIN_SERVER"},
+ {ModuleStatus, "SW_STATUS"},
+ {ModuleInspect, "SW_INSPECT"},
+ {ModuleUIManage, "SW_UI_MANAGEMENT"},
+ {ModuleDSLDebug, "SW_DSL_DEBUGGING"},
+ {ModuleRuntimeRule, "SW_RECEIVER_RUNTIME_RULE"},
+}
+
+// Run reads GET /debugging/config/dump from the admin host and reports which
feature
+// modules are enabled. A module is considered enabled when any dotted config
key
+// starts with `<module>.`. When the dump cannot be fetched, AdminReachable is
false,
+// every module reports disabled, and the transport error is returned
alongside the
+// (still useful) Result so callers can surface the admin URL.
+func Run(ctx context.Context) (*Result, error) {
+ r := &Result{AdminURL: client.BaseURL(ctx), enabled: map[string]bool{}}
+
+ var dump map[string]any
+ err := client.GetJSON(ctx, "/debugging/config/dump", nil, &dump)
+ if err == nil {
+ r.AdminReachable = true
+ for k := range dump {
+ prefix := k
+ if i := strings.IndexByte(k, '.'); i >= 0 {
+ prefix = k[:i]
+ }
+ r.enabled[prefix] = true
+ }
+ }
+ for _, m := range knownModules {
+ r.Modules = append(r.Modules, Module{Name: m.name, EnvVar:
m.env, Enabled: r.enabled[m.name]})
+ }
+ return r, err
+}
+
+// Explain enriches an admin-call error with operator-actionable context. A
transport
+// failure is reported as an unreachable admin host. A 404 with no
recognizable JSON
+// error envelope is reported as a likely-disabled module (the route is not
registered),
+// whereas a 404 that carries an error body (e.g. {"error":"not_found"}) is a
real
+// resource miss from an enabled module and is returned unchanged — as are all
other API
+// errors (400/409/421/...), which are already specific.
+func Explain(ctx context.Context, err error, module, envVar string) error {
+ if err == nil {
+ return nil
+ }
+ adminURL := client.BaseURL(ctx)
+ var apiErr *client.APIError
+ if errors.As(err, &apiErr) {
+ if apiErr.StatusCode == http.StatusNotFound && apiErr.Message
== "" && apiErr.Status == "" {
+ return fmt.Errorf("the `%s` admin feature module
appears disabled on OAP "+
+ "(HTTP 404 with no error body at %s); enable it
with %s=default. original error: %w",
+ module, adminURL, envVar, err)
+ }
+ return err
+ }
+ return fmt.Errorf("could not reach the OAP admin-server at %s; "+
+ "verify --admin-url and that admin-server is enabled
(SW_ADMIN_SERVER=default). "+
+ "original error: %w", adminURL, err)
+}
diff --git a/pkg/admin/runtimerule/runtimerule.go
b/pkg/admin/runtimerule/runtimerule.go
new file mode 100644
index 0000000..c98b236
--- /dev/null
+++ b/pkg/admin/runtimerule/runtimerule.go
@@ -0,0 +1,211 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package runtimerule wraps the OAP admin-server `receiver-runtime-rule`
feature
+// module: hot-update of MAL / LAL rule files without restarting OAP, plus
read access
+// to the live and bundled rule state.
+package runtimerule
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/apache/skywalking-cli/pkg/admin/client"
+)
+
+// Catalogs accepted by the runtime-rule endpoints.
+var Catalogs = []string{"otel-rules", "log-mal-rules", "telegraf-rules", "lal"}
+
+// ApplyResult is the JSON envelope returned by addOrUpdate / inactivate /
delete.
+type ApplyResult struct {
+ ApplyStatus string `json:"applyStatus"`
+ Catalog string `json:"catalog"`
+ Name string `json:"name"`
+ Message string `json:"message"`
+}
+
+// Rule is the result of a single-rule GET. Metadata comes from X-Sw-* response
+// headers; Content is the raw YAML body. NotModified is set when a
conditional GET
+// (If-None-Match) returns 304.
+type Rule struct {
+ Status string `json:"status"`
+ Source string `json:"source"`
+ ContentHash string `json:"contentHash"`
+ UpdateTime int64 `json:"updateTime"`
+ ETag string `json:"etag"`
+ Content string `json:"content,omitempty"`
+ NotModified bool `json:"notModified,omitempty"`
+}
+
+// List returns the live rule state per node (GET
/runtime/rule/list[?catalog=]).
+func List(ctx context.Context, catalog string) (any, error) {
+ var query url.Values
+ if catalog != "" {
+ query = url.Values{"catalog": []string{catalog}}
+ }
+ var out any
+ err := client.GetJSON(ctx, "/runtime/rule/list", query, &out)
+ return out, err
+}
+
+// ListBundled returns the static (bundled) rule twins for a catalog
+// (GET /runtime/rule/bundled?catalog=&withContent=).
+func ListBundled(ctx context.Context, catalog string, withContent bool) (any,
error) {
+ query := url.Values{
+ "catalog": []string{catalog},
+ "withContent": []string{strconv.FormatBool(withContent)},
+ }
+ var out any
+ err := client.GetJSON(ctx, "/runtime/rule/bundled", query, &out)
+ return out, err
+}
+
+// Get fetches a single rule (GET /runtime/rule?catalog=&name=[&source=]). It
negotiates
+// raw YAML and reads metadata from the X-Sw-* headers. When ifNoneMatch is
set and the
+// server replies 304, the returned Rule has NotModified=true and an empty
Content.
+func Get(ctx context.Context, catalog, name, source, ifNoneMatch string)
(*Rule, error) {
+ query := url.Values{"catalog": []string{catalog}, "name":
[]string{name}}
+ if source != "" {
+ query.Set("source", source)
+ }
+ headers := map[string]string{}
+ if ifNoneMatch != "" {
+ headers["If-None-Match"] = ifNoneMatch
+ }
+
+ resp, err := client.Do(ctx, &client.Request{
+ Method: http.MethodGet,
+ Path: "/runtime/rule",
+ Query: query,
+ Accept: "application/x-yaml",
+ Headers: headers,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode == http.StatusNotModified {
+ return &Rule{
+ NotModified: true,
+ ETag: resp.Header.Get("ETag"),
+ ContentHash: resp.Header.Get("X-Sw-Content-Hash"),
+ Status: headerOr(resp, "X-Sw-Status", "n/a"),
+ }, nil
+ }
+ if !resp.IsSuccess() {
+ return nil, client.ParseError(resp)
+ }
+ updateTime, _ := strconv.ParseInt(resp.Header.Get("X-Sw-Update-Time"),
10, 64)
+ return &Rule{
+ Status: headerOr(resp, "X-Sw-Status", "n/a"),
+ Source: headerOr(resp, "X-Sw-Source", "runtime"),
+ ContentHash: resp.Header.Get("X-Sw-Content-Hash"),
+ UpdateTime: updateTime,
+ ETag: resp.Header.Get("ETag"),
+ Content: string(resp.Body),
+ }, nil
+}
+
+// AddOrUpdate pushes a new or updated rule as raw YAML
+// (POST
/runtime/rule/addOrUpdate?catalog=&name=[&allowStorageChange=][&force=]).
+func AddOrUpdate(ctx context.Context, catalog, name, body string,
allowStorageChange, force bool) (*ApplyResult, error) {
+ query := url.Values{"catalog": []string{catalog}, "name":
[]string{name}}
+ if allowStorageChange {
+ query.Set("allowStorageChange", "true")
+ }
+ if force {
+ query.Set("force", "true")
+ }
+ resp, err := client.Do(ctx, &client.Request{
+ Method: http.MethodPost,
+ Path: "/runtime/rule/addOrUpdate",
+ Query: query,
+ Body: strings.NewReader(body),
+ ContentType: "text/plain",
+ })
+ if err != nil {
+ return nil, err
+ }
+ return applyResult(resp)
+}
+
+// Inactivate turns a rule off (POST /runtime/rule/inactivate?catalog=&name=).
+func Inactivate(ctx context.Context, catalog, name string) (*ApplyResult,
error) {
+ query := url.Values{"catalog": []string{catalog}, "name":
[]string{name}}
+ resp, err := client.Do(ctx, &client.Request{Method: http.MethodPost,
Path: "/runtime/rule/inactivate", Query: query})
+ if err != nil {
+ return nil, err
+ }
+ return applyResult(resp)
+}
+
+// Delete removes a rule (POST
/runtime/rule/delete?catalog=&name=[&mode=revertToBundled]).
+// The server enforces a two-step gate: deleting an active rule returns a 409
+// requires_inactivate_first.
+func Delete(ctx context.Context, catalog, name, mode string) (*ApplyResult,
error) {
+ query := url.Values{"catalog": []string{catalog}, "name":
[]string{name}}
+ if mode != "" {
+ query.Set("mode", mode)
+ }
+ resp, err := client.Do(ctx, &client.Request{Method: http.MethodPost,
Path: "/runtime/rule/delete", Query: query})
+ if err != nil {
+ return nil, err
+ }
+ return applyResult(resp)
+}
+
+// Dump returns a tar.gz snapshot of all rules, or one catalog's
+// (GET /runtime/rule/dump[/{catalog}]).
+func Dump(ctx context.Context, catalog string) ([]byte, error) {
+ path := "/runtime/rule/dump"
+ if catalog != "" {
+ path += "/" + url.PathEscape(catalog)
+ }
+ resp, err := client.Do(ctx, &client.Request{Method: http.MethodGet,
Path: path, Accept: "application/gzip"})
+ if err != nil {
+ return nil, err
+ }
+ if !resp.IsSuccess() {
+ return nil, client.ParseError(resp)
+ }
+ return resp.Body, nil
+}
+
+func applyResult(resp *client.Response) (*ApplyResult, error) {
+ if !resp.IsSuccess() {
+ // Non-2xx still carries the ApplyResult envelope; ParseError
lifts its
+ // applyStatus/message so callers can switch on the semantic
code.
+ return nil, client.ParseError(resp)
+ }
+ var out ApplyResult
+ if len(resp.Body) > 0 {
+ if err := json.Unmarshal(resp.Body, &out); err != nil {
+ return nil, err
+ }
+ }
+ return &out, nil
+}
+
+func headerOr(resp *client.Response, key, fallback string) string {
+ if v := resp.Header.Get(key); v != "" {
+ return v
+ }
+ return fallback
+}
diff --git a/pkg/admin/status/status.go b/pkg/admin/status/status.go
new file mode 100644
index 0000000..2b7d661
--- /dev/null
+++ b/pkg/admin/status/status.go
@@ -0,0 +1,115 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package status wraps the OAP admin-server `status` feature module: cluster
+// membership, effective configuration / TTL, and alarm runtime status. These
+// endpoints were served on the public REST port before OAP 11.0.0 and now
live on
+// the admin host (default 17128); only /status/config/ttl is also mirrored on
12800.
+package status
+
+import (
+ "context"
+ "net/url"
+
+ "github.com/apache/skywalking-cli/pkg/admin/client"
+)
+
+// ClusterNode is a single OAP node as seen by the cluster coordinator.
+type ClusterNode struct {
+ Host string `json:"host"`
+ Port int `json:"port"`
+ Self bool `json:"self"`
+}
+
+// ClusterNodes is the response of GET /status/cluster/nodes.
+type ClusterNodes struct {
+ Nodes []ClusterNode `json:"nodes"`
+}
+
+// ClusterAlarmStatus is the per-node envelope returned by every
/status/alarm/* call.
+// OAP fans the query out across cluster members; non-evaluating nodes return
a stub
+// status and errorMsg is omitted on success.
+type ClusterAlarmStatus struct {
+ OapInstances []OapInstanceStatus `json:"oapInstances"`
+}
+
+// OapInstanceStatus is one cluster member's slice of an alarm status response.
+type OapInstanceStatus struct {
+ Address string `json:"address"`
+ ErrorMsg string `json:"errorMsg,omitempty"`
+ Status any `json:"status"`
+}
+
+// ClusterNodesQuery returns the OAP cluster peer list (GET
/status/cluster/nodes).
+// The self flag is normalized from either `self` or `isSelf` on the wire.
+func ClusterNodesQuery(ctx context.Context) (*ClusterNodes, error) {
+ var raw struct {
+ Nodes []struct {
+ Host string `json:"host"`
+ Port int `json:"port"`
+ Self bool `json:"self"`
+ IsSelf bool `json:"isSelf"`
+ } `json:"nodes"`
+ }
+ if err := client.GetJSON(ctx, "/status/cluster/nodes", nil, &raw); err
!= nil {
+ return nil, err
+ }
+ out := &ClusterNodes{Nodes: make([]ClusterNode, 0, len(raw.Nodes))}
+ for _, n := range raw.Nodes {
+ out.Nodes = append(out.Nodes, ClusterNode{Host: n.Host, Port:
n.Port, Self: n.Self || n.IsSelf})
+ }
+ return out, nil
+}
+
+// ConfigTTL returns the effective TTL configuration (GET /status/config/ttl).
+func ConfigTTL(ctx context.Context) (any, error) {
+ var out any
+ err := client.GetJSON(ctx, "/status/config/ttl", nil, &out)
+ return out, err
+}
+
+// ConfigDump returns the effective, secrets-redacted configuration as a flat
map of
+// `<module>.<provider>.<property>` keys (GET /debugging/config/dump).
+func ConfigDump(ctx context.Context) (any, error) {
+ var out any
+ err := client.GetJSON(ctx, "/debugging/config/dump", nil, &out)
+ return out, err
+}
+
+// AlarmRules returns the loaded alarm rules per OAP node (GET
/status/alarm/rules).
+func AlarmRules(ctx context.Context) (*ClusterAlarmStatus, error) {
+ var out ClusterAlarmStatus
+ err := client.GetJSON(ctx, "/status/alarm/rules", nil, &out)
+ return &out, err
+}
+
+// AlarmRule returns the definition + running state of a single alarm rule
+// (GET /status/alarm/{ruleId}).
+func AlarmRule(ctx context.Context, ruleID string) (*ClusterAlarmStatus,
error) {
+ var out ClusterAlarmStatus
+ err := client.GetJSON(ctx, "/status/alarm/"+url.PathEscape(ruleID),
nil, &out)
+ return &out, err
+}
+
+// AlarmRuleEntity returns the per-entity alarm window/evaluation state for a
rule
+// (GET /status/alarm/{ruleId}/{entityName}).
+func AlarmRuleEntity(ctx context.Context, ruleID, entityName string)
(*ClusterAlarmStatus, error) {
+ var out ClusterAlarmStatus
+ path := "/status/alarm/" + url.PathEscape(ruleID) + "/" +
url.PathEscape(entityName)
+ err := client.GetJSON(ctx, path, nil, &out)
+ return &out, err
+}
diff --git a/pkg/admin/uitemplate/uitemplate.go
b/pkg/admin/uitemplate/uitemplate.go
new file mode 100644
index 0000000..aac98cd
--- /dev/null
+++ b/pkg/admin/uitemplate/uitemplate.go
@@ -0,0 +1,92 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package uitemplate wraps the OAP admin-server `ui-management` feature
module: REST
+// CRUD over dashboard templates. It is the replacement for the GraphQL
+// UIConfigurationManagement template mutations retired in SkyWalking 11.0.0.
There is
+// no DELETE; templates are soft-disabled.
+package uitemplate
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+
+ "github.com/apache/skywalking-cli/pkg/admin/client"
+)
+
+const basePath = "/ui-management/templates"
+
+// Template is a dashboard template. Configuration is an opaque JSON-encoded
string.
+type Template struct {
+ ID string `json:"id"`
+ Configuration string `json:"configuration"`
+ Disabled bool `json:"disabled"`
+}
+
+// ChangeStatus is the acknowledgement of a create / update / disable write.
+type ChangeStatus struct {
+ ID string `json:"id"`
+ Status bool `json:"status"`
+ Message string `json:"message"`
+}
+
+// List returns all templates (GET /ui-management/templates). When
includingDisabled
+// is true, soft-disabled templates are included as well.
+func List(ctx context.Context, includingDisabled bool) ([]Template, error) {
+ var query url.Values
+ if includingDisabled {
+ query = url.Values{"includingDisabled": []string{"true"}}
+ }
+ var out []Template
+ err := client.GetJSON(ctx, basePath, query, &out)
+ return out, err
+}
+
+// Get returns a single template by ID (GET /ui-management/templates/{id}).
+func Get(ctx context.Context, id string) (*Template, error) {
+ var out Template
+ err := client.GetJSON(ctx, basePath+"/"+url.PathEscape(id), nil, &out)
+ return &out, err
+}
+
+// Create adds a new template (POST /ui-management/templates). configuration
is the
+// JSON-encoded template body. Since OAP 11.0.0 (skywalking#13884) the id is
required
+// in the request body, so callers pass a client-generated id.
+func Create(ctx context.Context, id, configuration string) (*ChangeStatus,
error) {
+ var out ChangeStatus
+ body := map[string]string{"id": id, "configuration": configuration}
+ err := client.SendJSON(ctx, http.MethodPost, basePath, nil, body, &out)
+ return &out, err
+}
+
+// Update replaces an existing template (PUT /ui-management/templates).
+func Update(ctx context.Context, id, configuration string) (*ChangeStatus,
error) {
+ var out ChangeStatus
+ body := map[string]string{"id": id, "configuration": configuration}
+ err := client.SendJSON(ctx, http.MethodPut, basePath, nil, body, &out)
+ return &out, err
+}
+
+// Disable soft-disables a template (POST
/ui-management/templates/{id}/disable). It is
+// idempotent. The template row is preserved; only its disabled flag flips.
+func Disable(ctx context.Context, id string) (*ChangeStatus, error) {
+ out := ChangeStatus{ID: id, Status: true}
+ path := basePath + "/" + url.PathEscape(id) + "/disable"
+ err := client.SendJSON(ctx, http.MethodPost, path, nil, nil, &out)
+ return &out, err
+}
diff --git a/pkg/contextkey/contextkey.go b/pkg/contextkey/contextkey.go
index d687506..1071d03 100644
--- a/pkg/contextkey/contextkey.go
+++ b/pkg/contextkey/contextkey.go
@@ -19,6 +19,7 @@ package contextkey
type (
BaseURL struct{}
+ AdminURL struct{}
Insecure struct{}
Username struct{}
Password struct{}
diff --git a/pkg/graphql/alarm/alarm.go b/pkg/graphql/alarm/alarm.go
index 1e3a93a..bcafc76 100644
--- a/pkg/graphql/alarm/alarm.go
+++ b/pkg/graphql/alarm/alarm.go
@@ -29,24 +29,47 @@ import (
)
type ListAlarmCondition struct {
- Duration *api.Duration
- Keyword string
- Scope api.Scope
- Tags []*api.AlarmTag
- Paging *api.Pagination
+ Duration *api.Duration
+ Keyword string
+ Tags []*api.AlarmTag
+ Paging *api.Pagination
+ Layer string
+ RuleNames []string
+ Entities []*api.Entity
+}
+
+// alarmQueryCondition mirrors the GraphQL `AlarmQueryCondition` input of
`queryAlarms`.
+// It is JSON-encoded as the `condition` variable, so the field tags must
match the
+// schema field names exactly.
+type alarmQueryCondition struct {
+ Duration *api.Duration `json:"duration"`
+ Paging *api.Pagination `json:"paging"`
+ Entities []*api.Entity `json:"entities,omitempty"`
+ Layer *string `json:"layer,omitempty"`
+ RuleNames []string `json:"ruleNames,omitempty"`
+ Keyword *string `json:"keyword,omitempty"`
+ Tags []*api.AlarmTag `json:"tags,omitempty"`
}
func Alarms(ctx context.Context, condition *ListAlarmCondition) (api.Alarms,
error) {
var response map[string]api.Alarms
- request :=
graphql.NewRequest(assets.Read("graphqls/alarm/alarms.graphql"))
- request.Var("paging", condition.Paging)
- request.Var("tags", condition.Tags)
- request.Var("duration", condition.Duration)
- request.Var("keyword", condition.Keyword)
- if condition.Scope != "" {
- request.Var("scope", condition.Scope)
+ queryCondition := alarmQueryCondition{
+ Duration: condition.Duration,
+ Paging: condition.Paging,
+ Entities: condition.Entities,
+ RuleNames: condition.RuleNames,
+ Tags: condition.Tags,
+ }
+ if condition.Keyword != "" {
+ queryCondition.Keyword = &condition.Keyword
}
+ if condition.Layer != "" {
+ queryCondition.Layer = &condition.Layer
+ }
+
+ request :=
graphql.NewRequest(assets.Read("graphqls/alarm/alarms.graphql"))
+ request.Var("condition", queryCondition)
err := client.ExecuteQuery(ctx, request, &response)
diff --git a/pkg/graphql/client/client.go b/pkg/graphql/client/client.go
index a2ad60a..67f4d9b 100644
--- a/pkg/graphql/client/client.go
+++ b/pkg/graphql/client/client.go
@@ -19,29 +19,23 @@ package client
import (
"context"
- "crypto/tls"
- "encoding/base64"
- "net/http"
"github.com/machinebox/graphql"
"github.com/apache/skywalking-cli/pkg/contextkey"
"github.com/apache/skywalking-cli/pkg/logger"
+ "github.com/apache/skywalking-cli/pkg/transport"
)
// newClient creates a new GraphQL client with configuration from context.
func newClient(ctx context.Context) *graphql.Client {
options := []graphql.ClientOption{}
- insecure := ctx.Value(contextkey.Insecure{}).(bool)
- if insecure {
- customTransport :=
http.DefaultTransport.(*http.Transport).Clone()
- customTransport.TLSClientConfig =
&tls.Config{InsecureSkipVerify: insecure} // #nosec G402
- httpClient := &http.Client{Transport: customTransport}
- options = append(options, graphql.WithHTTPClient(httpClient))
+ if transport.Insecure(ctx) {
+ options = append(options,
graphql.WithHTTPClient(transport.HTTPClient(ctx)))
}
- baseURL := getValue(ctx, contextkey.BaseURL{},
"http://127.0.0.1:12800/graphql")
+ baseURL := transport.GetValue(ctx, contextkey.BaseURL{},
"http://127.0.0.1:12800/graphql")
client := graphql.NewClient(baseURL, options...)
client.Log = func(msg string) {
logger.Log.Debugln(msg)
@@ -51,14 +45,7 @@ func newClient(ctx context.Context) *graphql.Client {
// ExecuteQuery executes the `request` and parse to the `response`, returning
`error` if there is any.
func ExecuteQuery(ctx context.Context, request *graphql.Request, response any)
error {
- username := getValue(ctx, contextkey.Username{}, "")
- password := getValue(ctx, contextkey.Password{}, "")
- authorization := getValue(ctx, contextkey.Authorization{}, "")
-
- if authorization == "" && username != "" && password != "" {
- authorization = "Basic " +
base64.StdEncoding.EncodeToString([]byte(username+":"+password))
- }
- if authorization != "" {
+ if authorization := transport.AuthHeader(ctx); authorization != "" {
request.Header.Set("Authorization", authorization)
}
@@ -66,12 +53,3 @@ func ExecuteQuery(ctx context.Context, request
*graphql.Request, response any) e
err := client.Run(ctx, request, response)
return err
}
-
-// getValue safely extracts a value from the context.
-func getValue[T any](ctx context.Context, key any, defaultValue T) T {
- val := ctx.Value(key)
- if v, ok := val.(T); ok {
- return v
- }
- return defaultValue
-}
diff --git a/pkg/transport/transport.go b/pkg/transport/transport.go
new file mode 100644
index 0000000..ae8066a
--- /dev/null
+++ b/pkg/transport/transport.go
@@ -0,0 +1,71 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package transport holds the connection bits shared by the GraphQL client and
+// the admin REST client: TLS handling, basic-auth resolution, and context
helpers.
+package transport
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/base64"
+ "net/http"
+
+ "github.com/apache/skywalking-cli/pkg/contextkey"
+)
+
+// Insecure reports whether TLS certificate verification should be skipped,
+// according to the `--insecure` global option stored in the context.
+func Insecure(ctx context.Context) bool {
+ return GetValue(ctx, contextkey.Insecure{}, false)
+}
+
+// HTTPClient builds an *http.Client honoring the `--insecure` option from
context.
+// When insecure is not set it returns a plain client, equivalent to
http.DefaultClient.
+func HTTPClient(ctx context.Context) *http.Client {
+ if Insecure(ctx) {
+ customTransport :=
http.DefaultTransport.(*http.Transport).Clone()
+ customTransport.TLSClientConfig =
&tls.Config{InsecureSkipVerify: true} // #nosec G402
+ return &http.Client{Transport: customTransport}
+ }
+ return &http.Client{}
+}
+
+// AuthHeader resolves the value of the `Authorization` header from the global
+// `--authorization`, `--username` and `--password` options stored in the
context,
+// returning an empty string when no credentials are configured. A raw
+// `--authorization` takes precedence over `--username`/`--password`.
+func AuthHeader(ctx context.Context) string {
+ username := GetValue(ctx, contextkey.Username{}, "")
+ password := GetValue(ctx, contextkey.Password{}, "")
+ authorization := GetValue(ctx, contextkey.Authorization{}, "")
+
+ if authorization == "" && username != "" && password != "" {
+ authorization = "Basic " +
base64.StdEncoding.EncodeToString([]byte(username+":"+password))
+ }
+ return authorization
+}
+
+// GetValue safely extracts a typed value from the context, falling back to
+// defaultValue when the key is absent or holds a different type.
+func GetValue[T any](ctx context.Context, key any, defaultValue T) T {
+ val := ctx.Value(key)
+ if v, ok := val.(T); ok {
+ return v
+ }
+ return defaultValue
+}
diff --git a/test/base/docker-compose.yml b/test/base/docker-compose.yml
index ba5a944..aa84832 100644
--- a/test/base/docker-compose.yml
+++ b/test/base/docker-compose.yml
@@ -16,17 +16,20 @@
version: "2.1"
services:
- es:
- image: docker.elastic.co/elasticsearch/elasticsearch:7.10.1
+ # BanyanDB is SkyWalking's native, lightweight storage — far cheaper to spin
up in
+ # CI than Elasticsearch. The image tag is pinned to the same BanyanDB build
the
+ # upstream skywalking master e2e validates against (test/e2e-v2/script/env:
+ # SW_BANYANDB_COMMIT); keep it in sync when bumping OAP_TAG.
+ banyandb:
+ image:
ghcr.io/apache/skywalking-banyandb:84b919efca3fee3d51df9e97a734a9f10ae6f1d2
expose:
- - 9200
+ - 17912
+ - 17913
networks:
- test
- environment:
- - discovery.type=single-node
- - xpack.security.enabled=false
+ command: standalone --measure-metadata-cache-wait-duration 1m
--stream-metadata-cache-wait-duration 1m
healthcheck:
- test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/9200"]
+ test: ["CMD", "sh", "-c", "nc -nz 127.0.0.1 17912"]
interval: 5s
timeout: 60s
retries: 120
@@ -39,8 +42,8 @@ services:
networks:
- test
environment:
- - SW_STORAGE=elasticsearch
- - SW_STORAGE_ES_CLUSTER_NODES=es:9200
+ - SW_STORAGE=banyandb
+ - SW_STORAGE_BANYANDB_TARGETS=banyandb:17912
- SW_HEALTH_CHECKER=default
- SW_TELEMETRY=prometheus
healthcheck:
@@ -48,6 +51,7 @@ services:
interval: 5s
timeout: 60s
retries: 120
+ start_period: 30s
provider:
image: apache/skywalking-python:0.8.0-grpc-py3.9
diff --git a/test/cases/basic/docker-compose.yml
b/test/cases/admin/docker-compose.yml
similarity index 68%
copy from test/cases/basic/docker-compose.yml
copy to test/cases/admin/docker-compose.yml
index 30fe99f..1fe22d4 100644
--- a/test/cases/basic/docker-compose.yml
+++ b/test/cases/admin/docker-compose.yml
@@ -13,41 +13,28 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+# Minimal topology for the admin-server REST commands: just BanyanDB + OAP.
The admin
+# feature modules (status / inspect / ui-management / dsl-debugging /
runtime-rule)
+# are enabled by default in OAP 11.0.0+, so no extra environment is required —
only
+# the admin REST port (17128) needs to be published alongside the GraphQL port
(12800).
+# Requires an OAP build that includes the admin host (OAP_TAG >= 11.0.0).
+
version: "2.1"
services:
- es:
+ banyandb:
extends:
file: ../../base/docker-compose.yml
- service: es
- ports:
- - 9200
+ service: banyandb
oap:
extends:
file: ../../base/docker-compose.yml
service: oap
ports:
- 12800
+ - 17128
depends_on:
- es:
- condition: service_healthy
-
- provider:
- extends:
- file: ../../base/docker-compose.yml
- service: provider
- depends_on:
- oap:
- condition: service_healthy
-
- consumer:
- extends:
- file: ../../base/docker-compose.yml
- service: consumer
- ports:
- - 9090
- depends_on:
- provider:
+ banyandb:
condition: service_healthy
networks:
diff --git a/test/cases/basic/expected/traces-list.yml
b/test/cases/admin/expected/count.yml
similarity index 75%
copy from test/cases/basic/expected/traces-list.yml
copy to test/cases/admin/expected/count.yml
index a51cb7f..56181bf 100644
--- a/test/cases/basic/expected/traces-list.yml
+++ b/test/cases/admin/expected/count.yml
@@ -13,15 +13,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-traces:
- {{- contains .traces }}
-- segmentid: {{ notEmpty .segmentid }}
- endpointnames:
- - /users
- duration: {{ ge .duration 0 }}
- start: "{{ notEmpty .start}}"
- iserror: false
- traceids:
- - {{ index .traceids 0 }}
- {{- end }}
-debuggingtrace: null
\ No newline at end of file
+{{ gt . 0 }}
diff --git a/test/cases/basic/expected/traces-list.yml
b/test/cases/admin/expected/ok.yml
similarity index 75%
copy from test/cases/basic/expected/traces-list.yml
copy to test/cases/admin/expected/ok.yml
index a51cb7f..e33f055 100644
--- a/test/cases/basic/expected/traces-list.yml
+++ b/test/cases/admin/expected/ok.yml
@@ -13,15 +13,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-traces:
- {{- contains .traces }}
-- segmentid: {{ notEmpty .segmentid }}
- endpointnames:
- - /users
- duration: {{ ge .duration 0 }}
- start: "{{ notEmpty .start}}"
- iserror: false
- traceids:
- - {{ index .traceids 0 }}
- {{- end }}
-debuggingtrace: null
\ No newline at end of file
+ok
diff --git a/test/cases/basic/expected/traces-list.yml
b/test/cases/admin/expected/true.yml
similarity index 75%
copy from test/cases/basic/expected/traces-list.yml
copy to test/cases/admin/expected/true.yml
index a51cb7f..1549354 100644
--- a/test/cases/basic/expected/traces-list.yml
+++ b/test/cases/admin/expected/true.yml
@@ -13,15 +13,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-traces:
- {{- contains .traces }}
-- segmentid: {{ notEmpty .segmentid }}
- endpointnames:
- - /users
- duration: {{ ge .duration 0 }}
- start: "{{ notEmpty .start}}"
- iserror: false
- traceids:
- - {{ index .traceids 0 }}
- {{- end }}
-debuggingtrace: null
\ No newline at end of file
+true
diff --git a/test/cases/admin/test.yaml b/test/cases/admin/test.yaml
new file mode 100644
index 0000000..f521f09
--- /dev/null
+++ b/test/cases/admin/test.yaml
@@ -0,0 +1,72 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# E2E coverage for the OAP admin-server REST commands (`swctl admin ...`) and
the
+# OAP 11.0.0 regression fixes (alarm -> queryAlarms, menu retirement
detection).
+# Requires an admin-capable OAP (OAP_TAG >= 11.0.0). The admin host needs no
traffic,
+# so this suite does not run the provider/consumer demo apps or a trigger.
+
+setup:
+ env: compose
+ file: docker-compose.yml
+ timeout: 20m
+ steps:
+ - name: install yq
+ command: yq > /dev/null 2>&1 || go install
github.com/mikefarah/yq/v4@latest
+
+verify:
+ retry:
+ count: 20
+ interval: 10s
+ cases:
+ # admin-server reachability + feature detection (GET
/debugging/config/dump).
+ - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128}
admin preflight | yq e '.adminReachable' -
+ expected: expected/true.yml
+
+ # status module — cluster membership, effective config, alarm runtime
status.
+ - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128}
admin cluster nodes | yq e 'has("nodes")' -
+ expected: expected/true.yml
+ - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128}
admin config dump | yq e 'length' -
+ expected: expected/count.yml
+ - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128}
admin alarm rules | yq e 'has("oapInstances")' -
+ expected: expected/true.yml
+
+ # inspect module — metric catalog.
+ - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128}
admin inspect metrics --mqe-queryable | yq e '.metrics | length' -
+ expected: expected/count.yml
+
+ # ui-management module — dashboard templates (a sequence, possibly empty).
+ - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128}
admin ui-template list | yq e 'length > -1' -
+ expected: expected/true.yml
+
+ # runtime-rule module — live rule state.
+ - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128}
admin runtime-rule list | yq e 'has("rules")' -
+ expected: expected/true.yml
+
+ # dsl-debugging module + OAL picker.
+ - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128}
admin dsl-debug status | yq e 'has("module")' -
+ expected: expected/true.yml
+ - query: swctl --display json --admin-url=http://${oap_host}:${oap_17128}
admin oal files | yq e '.files | length' -
+ expected: expected/count.yml
+
+ # regression — `alarm list` migrated from getAlarm to queryAlarms (GraphQL
on 12800).
+ - query: swctl --display json
--base-url=http://${oap_host}:${oap_12800}/graphql alarm list | yq e
'has("msgs")' -
+ expected: expected/true.yml
+
+ # regression — `menu get` reports the 11.0.0 retirement gracefully instead
of a raw error.
+ - query: |
+ out=$(swctl --base-url=http://${oap_host}:${oap_12800}/graphql menu
get 2>&1 || true)
+ echo "$out" | grep -q "no longer serves the UI menu" && echo ok
+ expected: expected/ok.yml
diff --git a/test/cases/basic/docker-compose.yml
b/test/cases/basic/docker-compose.yml
index 30fe99f..2622828 100644
--- a/test/cases/basic/docker-compose.yml
+++ b/test/cases/basic/docker-compose.yml
@@ -16,12 +16,10 @@
version: "2.1"
services:
- es:
+ banyandb:
extends:
file: ../../base/docker-compose.yml
- service: es
- ports:
- - 9200
+ service: banyandb
oap:
extends:
file: ../../base/docker-compose.yml
@@ -29,7 +27,7 @@ services:
ports:
- 12800
depends_on:
- es:
+ banyandb:
condition: service_healthy
provider:
diff --git a/test/cases/basic/expected/layer-list.yml
b/test/cases/basic/expected/layer-list.yml
index 360f638..ff59649 100644
--- a/test/cases/basic/expected/layer-list.yml
+++ b/test/cases/basic/expected/layer-list.yml
@@ -13,50 +13,55 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-- CLICKHOUSE
+# The query pipes through `yq e 'sort'`, so this is the layer set in sorted
order — the
+# OAP layer registry's own iteration order is not stable across builds, so we
normalize
+# both sides by sorting. Update this set when the OAP layer list changes.
+- ACTIVEMQ
+- ALIPAY_MINI_PROGRAM
+- APISIX
+- AWS_DYNAMODB
+- AWS_EKS
+- AWS_GATEWAY
+- AWS_S3
+- BANYANDB
+- BOOKKEEPER
- BROWSER
-- RABBITMQ
-- MESH
-- GENERAL
+- CACHE
+- CILIUM_SERVICE
+- CLICKHOUSE
+- DATABASE
+- ELASTICSEARCH
+- ENVOY_AI_GATEWAY
- FAAS
-- MESH_CP
-- AWS_GATEWAY
+- FLINK
- GENAI
-- BANYANDB
-- NGINX
-- SO11Y_JAVA_AGENT
-- ACTIVEMQ
-- SO11Y_SATELLITE
+- GENERAL
+- IOS
+- K8S
- K8S_SERVICE
-- VIRTUAL_GATEWAY
-- CILIUM_SERVICE
-- AWS_EKS
+- KAFKA
+- KONG
+- MESH
+- MESH_CP
+- MESH_DP
+- MONGODB
- MQ
- MYSQL
-- VIRTUAL_DATABASE
-- K8S
-- VIRTUAL_MQ
-- PULSAR
-- MONGODB
-- CACHE
-- OS_WINDOWS
-- SO11Y_GO_AGENT
-- ENVOY_AI_GATEWAY
-- MESH_DP
-- VIRTUAL_GENAI
-- SO11Y_OAP
-- DATABASE
+- NGINX
- OS_LINUX
+- OS_WINDOWS
+- POSTGRESQL
+- PULSAR
+- RABBITMQ
- REDIS
-- KAFKA
-- ELASTICSEARCH
- ROCKETMQ
-- APISIX
+- SO11Y_GO_AGENT
+- SO11Y_JAVA_AGENT
+- SO11Y_OAP
+- SO11Y_SATELLITE
- VIRTUAL_CACHE
-- POSTGRESQL
-- AWS_S3
-- BOOKKEEPER
-- KONG
-- FLINK
-- AWS_DYNAMODB
-
+- VIRTUAL_DATABASE
+- VIRTUAL_GATEWAY
+- VIRTUAL_GENAI
+- VIRTUAL_MQ
+- WECHAT_MINI_PROGRAM
diff --git a/test/cases/basic/expected/trace-users-detail.yml
b/test/cases/basic/expected/trace-users-detail.yml
deleted file mode 100644
index a286162..0000000
--- a/test/cases/basic/expected/trace-users-detail.yml
+++ /dev/null
@@ -1,103 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one or more
-# contributor license agreements. See the NOTICE file distributed with
-# this work for additional information regarding copyright ownership.
-# The ASF licenses this file to You under the Apache License, Version 2.0
-# (the "License"); you may not use this file except in compliance with
-# the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-spans:
- {{- contains .spans }}
- - traceid: {{ .traceid }}
- segmentid: {{ .segmentid }}
- spanid: {{ .spanid }}
- parentspanid: {{ .parentspanid }}
- refs: [ ]
- servicecode: consumer
- serviceinstancename: consumer1
- starttime: {{ gt .starttime 0 }}
- endtime: {{ gt .endtime 0 }}
- endpointname: /users
- type: Entry
- peer: {{ .peer }}
- component: Python
- iserror: false
- layer: Http
- tags:
- {{- contains .tags }}
- - key: http.method
- value: POST
- - key: http.url
- value: {{ notEmpty .value }}
- - key: http.status_code
- value: 200
- {{- end }}
- logs: [ ]
- attachedevents: []
- - traceid: {{ notEmpty .traceid }}
- segmentid: {{ .segmentid }}
- spanid: {{ .spanid }}
- parentspanid: {{ .parentspanid }}
- refs: [ ]
- servicecode: consumer
- serviceinstancename: consumer1
- starttime: {{ gt .starttime 0 }}
- endtime: {{ gt .endtime 0 }}
- endpointname: /users
- type: Exit
- peer: {{ .peer }}
- component: Python
- iserror: false
- layer: Http
- tags:
- {{- contains .tags }}
- - key: http.method
- value: POST
- - key: http.url
- value: {{ notEmpty .value }}
- - key: http.status_code
- value: 200
- {{- end }}
- logs: [ ]
- attachedevents: []
- - traceid: {{ notEmpty .traceid }}
- segmentid: {{ .segmentid }}
- spanid: {{ .spanid }}
- parentspanid: {{ .parentspanid }}
- refs:
- {{- contains .refs }}
- - traceid: {{ notEmpty .traceid }}
- parentsegmentid: {{ .parentsegmentid }}
- parentspanid: 1
- type: CROSS_PROCESS
- {{- end }}
- servicecode: provider
- serviceinstancename: provider1
- starttime: {{ gt .starttime 0 }}
- endtime: {{ gt .endtime 0 }}
- endpointname: /users
- type: Entry
- peer: {{ .peer }}
- component: Python
- iserror: false
- layer: Http
- tags:
- {{- contains .tags }}
- - key: http.method
- value: POST
- - key: http.url
- value: {{ notEmpty .value }}
- - key: http.status_code
- value: 200
- {{- end }}
- logs: [ ]
- attachedevents: []
- {{- end }}
-debuggingtrace: null
\ No newline at end of file
diff --git a/test/cases/basic/test.yaml b/test/cases/basic/test.yaml
index 6214a1c..c094692 100644
--- a/test/cases/basic/test.yaml
+++ b/test/cases/basic/test.yaml
@@ -84,7 +84,7 @@ verify:
- query: swctl --display yaml
--base-url=http://${oap_host}:${oap_12800}/graphql metrics single --name
service_instance_cpm --service-name provider --instance-name provider1
expected: expected/value.yml
- - query: swctl --display yaml
--base-url=http://${oap_host}:${oap_12800}/graphql layer list
+ - query: swctl --display yaml
--base-url=http://${oap_host}:${oap_12800}/graphql layer list | yq e 'sort' -
expected: expected/layer-list.yml
- query: swctl --display yaml
--base-url=http://${oap_host}:${oap_12800}/graphql service list
@@ -94,11 +94,11 @@ verify:
- query: swctl --display yaml
--base-url=http://${oap_host}:${oap_12800}/graphql service layer GENERAL
expected: expected/service.yml
- - query: swctl --display yaml
--base-url=http://${oap_host}:${oap_12800}/graphql trace ls
- expected: expected/traces-list.yml
- - query: |
- swctl --display yaml
--base-url=http://${oap_host}:${oap_12800}/graphql trace $( \
- swctl --display yaml
--base-url=http://${oap_host}:${oap_12800}/graphql trace ls \
- | yq e '.traces | select(.[].endpointnames[0]=="/users") |
.[0].traceids[0]' -
- )
- expected: expected/trace-users-detail.yml
+ # BanyanDB rejects the v1 trace API ("BanyanDB Trace Model changed, please
use
+ # queryTraces"), so trace coverage uses the v2 `trace-v2` command.
`trace-v2 list`
+ # returns full spans inline, so one command verifies both the listing and
the
+ # per-endpoint detail the old `trace`/`trace ls` cases covered.
+ - query: swctl --display yaml
--base-url=http://${oap_host}:${oap_12800}/graphql trace-v2 list | yq e
'.traces | length' -
+ expected: expected/value.yml
+ - query: swctl --display yaml
--base-url=http://${oap_host}:${oap_12800}/graphql trace-v2 list | yq e
'[.traces[].spans[] | select(.endpointname == "/users")] | length' -
+ expected: expected/value.yml
diff --git a/test/cases/basic/docker-compose.yml
b/test/cases/live-debugging/docker-compose.yml
similarity index 73%
copy from test/cases/basic/docker-compose.yml
copy to test/cases/live-debugging/docker-compose.yml
index 30fe99f..9edb61e 100644
--- a/test/cases/basic/docker-compose.yml
+++ b/test/cases/live-debugging/docker-compose.yml
@@ -13,23 +13,29 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+# Topology for the OAL live-debugging case: BanyanDB + OAP (admin REST on
17128) plus
+# the provider/consumer demo apps. Unlike the static `admin` case, the DSL live
+# debugger captures live ingest, so this case needs traffic — the
consumer→provider
+# calls drive Service / ServiceRelation source events through the shipped
core.oal
+# pipeline while the debug session is attached.
+# Requires an admin-capable OAP (OAP_TAG >= 11.0.0).
+
version: "2.1"
services:
- es:
+ banyandb:
extends:
file: ../../base/docker-compose.yml
- service: es
- ports:
- - 9200
+ service: banyandb
oap:
extends:
file: ../../base/docker-compose.yml
service: oap
ports:
- 12800
+ - 17128
depends_on:
- es:
+ banyandb:
condition: service_healthy
provider:
diff --git a/test/cases/basic/expected/traces-list.yml
b/test/cases/live-debugging/expected/ok.yml
similarity index 75%
rename from test/cases/basic/expected/traces-list.yml
rename to test/cases/live-debugging/expected/ok.yml
index a51cb7f..e33f055 100644
--- a/test/cases/basic/expected/traces-list.yml
+++ b/test/cases/live-debugging/expected/ok.yml
@@ -13,15 +13,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-traces:
- {{- contains .traces }}
-- segmentid: {{ notEmpty .segmentid }}
- endpointnames:
- - /users
- duration: {{ ge .duration 0 }}
- start: "{{ notEmpty .start}}"
- iserror: false
- traceids:
- - {{ index .traceids 0 }}
- {{- end }}
-debuggingtrace: null
\ No newline at end of file
+ok
diff --git a/test/cases/live-debugging/oal-debug-flow.sh
b/test/cases/live-debugging/oal-debug-flow.sh
new file mode 100755
index 0000000..6834df3
--- /dev/null
+++ b/test/cases/live-debugging/oal-debug-flow.sh
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Drives swctl's DSL live-debugger commands end-to-end against the OAL
pipeline:
+# admin dsl-debug status → injection enabled
+# admin dsl-debug session start (catalog=oal, name=core.oal,
ruleName=<metric>)
+# admin dsl-debug session get → poll until the live trace flow is captured
+# admin dsl-debug session stop
+#
+# The consumer→provider demo traffic fires the OAL source events that the
session
+# captures. Mirrors apache/skywalking test/e2e-v2/cases/dsl-debugging/oal but
exercises
+# swctl instead of curl. All diagnostics go to stderr; the only stdout is the
final
+# "ok" the e2e verify matches.
+
+set -euo pipefail
+
+log() { echo "[oal-debug] $*" >&2; }
+fail() { log "FAIL: $*"; exit 1; }
+
+OAP_HOST="${OAP_HOST:-127.0.0.1}"
+OAP_ADMIN_PORT="${OAP_ADMIN_PORT:-17128}"
+SETTLE_SECONDS="${SETTLE_SECONDS:-300}"
+
+# OAL debug is per-metric. service_cpm is a shipped core.oal rule on the
Service source
+# that every instrumented service fires on each inbound request, so the demo
traffic
+# drives it reliably.
+CATALOG="oal"
+NAME="core.oal"
+METRIC="${METRIC:-service_cpm}"
+# OAL source class the metric is derived from — surfaces as the input sample's
+# payload.type (service_cpm = from(Service.*).cpm() → Service).
+SOURCE_TYPE="${SOURCE_TYPE:-Service}"
+CLIENT_ID="e2e-oal-debug-$$"
+
+ADMIN=(--display=json "--admin-url=http://${OAP_HOST}:${OAP_ADMIN_PORT}")
+
+# --- Phase 0: module status
-----------------------------------------------------------
+log "=== Phase 0: dsl-debug status ==="
+swctl "${ADMIN[@]}" admin dsl-debug status | yq e '.injectionEnabled' - | grep
-qx true \
+ || fail "injectionEnabled is not true"
+
+# --- Phase 1: start session
-----------------------------------------------------------
+log "=== Phase 1: start OAL session (catalog=${CATALOG}, name=${NAME},
ruleName=${METRIC}) ==="
+start_out="$(swctl "${ADMIN[@]}" admin dsl-debug session start \
+ --catalog "${CATALOG}" --name "${NAME}" --rule-name "${METRIC}"
--client-id "${CLIENT_ID}")"
+log " start → ${start_out}"
+SESSION_ID="$(echo "${start_out}" | yq e '.sessionId // ""' -)"
+[ -n "${SESSION_ID}" ] || fail "session start did not return a sessionId"
+log "✓ session started: ${SESSION_ID}"
+
+# --- Phase 2: poll until the live OAL pipeline is captured
-----------------------------
+log "=== Phase 2: poll for captured records (budget ${SETTLE_SECONDS}s) ==="
+deadline=$(( $(date +%s) + SETTLE_SECONDS ))
+records=0
+body=""
+while [ "$(date +%s)" -lt "${deadline}" ]; do
+ body="$(swctl "${ADMIN[@]}" admin dsl-debug session get "${SESSION_ID}")"
+ records="$(echo "${body}" | yq e '[.nodes[].records[]] | length' -)"
+ [ "${records}" -gt 0 ] && break
+ sleep 5
+done
+[ "${records}" -gt 0 ] || fail "no records captured within ${SETTLE_SECONDS}s"
+log "✓ captured ${records} record(s)"
+
+# --- Phase 3: verify the capture is EXACTLY the bound metric
--------------------------
+log "=== Phase 3: assert the captured pipeline is exactly ${METRIC} ==="
+
+samples="$(echo "${body}" | yq e '[.nodes[].records[].samples[]] | length' -)"
+[ "${samples}" -gt 0 ] || fail "captured records carry no samples"
+
+# We assert the bound rule from the verbatim .dsl source and the output
samples rather
+# than the per-record .rule envelope, which the server does not reliably
populate for OAL.
+
+# 3a. Each record carries the verbatim core.oal source of ${METRIC}.
+dsl_hits="$(echo "${body}" | yq e "[.nodes[].records[] | select(.dsl |
contains(\"${METRIC}\"))] | length" -)"
+[ "${dsl_hits}" -gt 0 ] || fail "no record's .dsl carries the ${METRIC} OAL
source"
+
+# 3b. Source stage: an input sample drawn from the ${SOURCE_TYPE} source.
+src="$(echo "${body}" | yq e "[.nodes[].records[].samples[] | select(.type ==
\"input\" and .payload.type == \"${SOURCE_TYPE}\")] | length" -)"
+[ "${src}" -gt 0 ] || fail "no input sample from the ${SOURCE_TYPE} source"
+
+# 3c. Aggregation stage: the verbatim cpm() function from the rule.
+agg="$(echo "${body}" | yq e '[.nodes[].records[].samples[] | select(.type ==
"aggregation" and .sourceText == "cpm()")] | length' -)"
+[ "${agg}" -gt 0 ] || fail "no cpm() aggregation sample for ${METRIC}"
+
+# 3d. Output stage: the materialised ${METRIC} metric.
+out="$(echo "${body}" | yq e "[.nodes[].records[].samples[] | select(.type ==
\"output\" and .sourceText == \"${METRIC}\")] | length" -)"
+[ "${out}" -gt 0 ] || fail "no output sample for metric ${METRIC}"
+
+# 3e. Per-metric gate isolation: no OTHER metric's output leaked through this
session.
+leak="$(echo "${body}" | yq e "[.nodes[].records[].samples[] | select(.type ==
\"output\" and .sourceText != \"${METRIC}\")] | length" -)"
+[ "${leak}" = "0" ] || fail "${leak} output sample(s) for a metric other than
${METRIC} leaked into the session"
+
+log "✓ capture is exactly ${METRIC}: ${samples} samples — ${SOURCE_TYPE}
source → cpm() → ${METRIC} output, no other metric leaked"
+
+# --- Phase 4: stop session
------------------------------------------------------------
+log "=== Phase 4: stop session ==="
+swctl "${ADMIN[@]}" admin dsl-debug session stop "${SESSION_ID}" | yq e
'.localStopped' - | grep -qx true \
+ || fail "localStopped is not true"
+
+log "=== OAL LIVE-DEBUG FLOW PASSED (${records} records, ${samples} samples)
==="
+echo ok
diff --git a/test/cases/live-debugging/test.yaml
b/test/cases/live-debugging/test.yaml
new file mode 100644
index 0000000..5111b4e
--- /dev/null
+++ b/test/cases/live-debugging/test.yaml
@@ -0,0 +1,46 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# OAL live-debugging E2E: drives swctl's `admin dsl-debug session` lifecycle
against a
+# real OAL capture. The trigger sends continuous traffic so the OAL pipeline
keeps
+# firing while the debug session is attached; the flow script does the stateful
+# start → poll → assert → stop and prints "ok" on success. Requires an
admin-capable
+# OAP (OAP_TAG >= 11.0.0).
+
+setup:
+ env: compose
+ file: docker-compose.yml
+ timeout: 25m
+ steps:
+ - name: install yq
+ command: yq > /dev/null 2>&1 || go install
github.com/mikefarah/yq/v4@latest
+
+trigger:
+ action: http
+ interval: 3s
+ # Continuous traffic: the live debugger captures ingest as it flows, so the
OAL
+ # pipeline must keep firing for the whole session lifecycle.
+ times: -1
+ url: http://${consumer_host}:${consumer_9090}/users
+ method: POST
+
+verify:
+ # The flow script owns its own polling/retry budget, so a single verify pass
is enough.
+ retry:
+ count: 1
+ interval: 1s
+ cases:
+ - query: OAP_HOST=${oap_host} OAP_ADMIN_PORT=${oap_17128} bash
test/cases/live-debugging/oal-debug-flow.sh
+ expected: expected/ok.yml