diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index b8960e4f..f7687923 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 3af98966..6de747b0 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-rule); and `admin dsl-debug status|sessions|session start|get|stop` plus `admin oal files|file|rules|rule` (dsl-debugging). ### 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 f52b8879..06afeb79 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 da2cbbe1..1e4b477f 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 6a298838..01ee2c93 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 ``", diff --git a/examples/.skywalking.yaml b/examples/.skywalking.yaml index 55125f60..4adaf922 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 00000000..e77b0425 --- /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 00000000..fdf3ead1 --- /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: " []", + 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 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/admin/cluster/cluster.go b/internal/commands/admin/cluster/cluster.go new file mode 100644 index 00000000..26229fac --- /dev/null +++ b/internal/commands/admin/cluster/cluster.go @@ -0,0 +1,52 @@ +// 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 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" +) + +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. List cluster nodes: +$ swctl admin cluster nodes`, + Action: func(ctx *cli.Context) error { + nodes, err := status.ClusterNodesQuery(ctx.Context) + if err != nil { + return preflight.Explain(ctx.Context, err, preflight.ModuleStatus, "SW_STATUS") + } + 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 00000000..f41468fe --- /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 +".." 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 00000000..2d049532 --- /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: "", + Action: func(ctx *cli.Context) error { + id := ctx.Args().Get(0) + if id == "" { + return fmt.Errorf("a 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: "", + Action: func(ctx *cli.Context) error { + id := ctx.Args().Get(0) + if id == "" { + return fmt.Errorf("a 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 00000000..528d6c65 --- /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 00000000..45805fb4 --- /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: "", + Action: func(ctx *cli.Context) error { + name := ctx.Args().Get(0) + if name == "" { + return fmt.Errorf("a 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: "", + Action: func(ctx *cli.Context) error { + source := ctx.Args().Get(0) + if source == "" { + return fmt.Errorf("a 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/admin/preflight.go b/internal/commands/admin/preflight.go new file mode 100644 index 00000000..1eea7f17 --- /dev/null +++ b/internal/commands/admin/preflight.go @@ -0,0 +1,47 @@ +// 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/pkg/admin/preflight" + "github.com/apache/skywalking-cli/pkg/display" + "github.com/apache/skywalking-cli/pkg/display/displayable" +) + +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. Check admin feature availability: +$ swctl admin preflight`, + Action: func(ctx *cli.Context) error { + // 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: result}) + }, +} diff --git a/internal/commands/admin/runtimerule/runtimerule.go b/internal/commands/admin/runtimerule/runtimerule.go new file mode 100644 index 00000000..fc93803d --- /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 00000000..9db9fc19 --- /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: "", + Action: func(ctx *cli.Context) error { + id := ctx.Args().Get(0) + if id == "" { + return fmt.Errorf("an 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 -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: "", + Action: func(ctx *cli.Context) error { + id := ctx.Args().Get(0) + if id == "" { + return fmt.Errorf("an 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 beae9081..54306305 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 9cb873ea..32bbf0b5 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 c7b9aac5..d41ae1f5 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 00000000..ec431ac2 --- /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 00000000..006b93f3 --- /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 00000000..a87fb529 --- /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 00000000..6ff9807a --- /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 00000000..ad9c3e97 --- /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 00000000..963d7db1 --- /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 `.`. 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 00000000..c98b2366 --- /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 00000000..2b7d661d --- /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 +// `..` 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 00000000..aac98cd5 --- /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 d6875066..1071d03f 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 1e3a93a9..bcafc766 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 a2ad60a6..67f4d9ba 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 00000000..ae8066ad --- /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 ba5a9446..aa848320 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/admin/docker-compose.yml b/test/cases/admin/docker-compose.yml new file mode 100644 index 00000000..1fe22d42 --- /dev/null +++ b/test/cases/admin/docker-compose.yml @@ -0,0 +1,41 @@ +# 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. + +# 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: + banyandb: + extends: + file: ../../base/docker-compose.yml + service: banyandb + oap: + extends: + file: ../../base/docker-compose.yml + service: oap + ports: + - 12800 + - 17128 + depends_on: + banyandb: + condition: service_healthy + +networks: + test: diff --git a/test/cases/basic/expected/traces-list.yml b/test/cases/admin/expected/count.yml similarity index 75% rename from test/cases/basic/expected/traces-list.yml rename to test/cases/admin/expected/count.yml index a51cb7f4..56181bf8 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/admin/expected/ok.yml b/test/cases/admin/expected/ok.yml new file mode 100644 index 00000000..e33f0553 --- /dev/null +++ b/test/cases/admin/expected/ok.yml @@ -0,0 +1,16 @@ +# 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. + +ok diff --git a/test/cases/admin/expected/true.yml b/test/cases/admin/expected/true.yml new file mode 100644 index 00000000..1549354e --- /dev/null +++ b/test/cases/admin/expected/true.yml @@ -0,0 +1,16 @@ +# 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. + +true diff --git a/test/cases/admin/test.yaml b/test/cases/admin/test.yaml new file mode 100644 index 00000000..f521f09d --- /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 30fe99f5..26228287 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 360f638f..ff596492 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 a2861626..00000000 --- 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 6214a1ca..c094692e 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/live-debugging/docker-compose.yml b/test/cases/live-debugging/docker-compose.yml new file mode 100644 index 00000000..9edb61e7 --- /dev/null +++ b/test/cases/live-debugging/docker-compose.yml @@ -0,0 +1,60 @@ +# 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. + +# 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: + banyandb: + extends: + file: ../../base/docker-compose.yml + service: banyandb + oap: + extends: + file: ../../base/docker-compose.yml + service: oap + ports: + - 12800 + - 17128 + depends_on: + banyandb: + 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: + condition: service_healthy + +networks: + test: diff --git a/test/cases/live-debugging/expected/ok.yml b/test/cases/live-debugging/expected/ok.yml new file mode 100644 index 00000000..e33f0553 --- /dev/null +++ b/test/cases/live-debugging/expected/ok.yml @@ -0,0 +1,16 @@ +# 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. + +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 00000000..6834df30 --- /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=) +# 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 00000000..5111b4e2 --- /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