diff --git a/AGENTS.md b/AGENTS.md index eb4efe0..676a0cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,7 @@ These are the current registered systems in `cmd/system/register.go`: | `gse` | `cmd/system/gse` | Go-implemented | | `devops` | `cmd/system/devops` | subsystem-based mixed YAML + Go | | `nodeman` | `cmd/system/nodeman` | Go-implemented | +| `monitor` | `cmd/system/monitor` | reviewed per-tool HTTP routes; V1/V2 Profiles; context/auth and final output remain shared | ## Command registration model @@ -314,7 +315,7 @@ When a Go-implemented action supports both named flags and raw `--body`, treat ` `api` and public system actions register `output.AddJQFlag(cmd)` locally. Go actions inherit it through `AddCommonRequestFlags` / `AddCommonRequestFlagsWithoutBody`; YAML actions register it in their builder. `jq` is reserved for generated flags. -Write final request results through `output.WriteCommandEnvelope(cmd, env)`, after any result checks, redaction, response mutation, or pagination aggregation. Keep intermediate requests in the shared request layer without projecting them. The output helper compiles during flag parsing, buffers JSON Lines with finite execution/output budgets, bypasses failed envelopes, and preserves completed request results on projection failures. Keep unsupported commands explicit instead of adding a root flag that can be silently ignored. Detailed semantics are defined in `docs/design.md`. +Write final request results through `output.WriteCommandEnvelope(cmd, env)` (or `WriteCommandEnvelopeWithMetadata` for command-local provenance), after any result checks, redaction, response mutation, or pagination aggregation. Keep intermediate requests in the shared request layer without projecting them. The output helper compiles during flag parsing, buffers JSON Lines with finite execution/output budgets, bypasses failed envelopes, and preserves completed request results on projection failures. Keep unsupported commands explicit instead of adding a root flag that can be silently ignored. Detailed semantics are defined in `docs/design.md`. ## Runtime, auth, and request behavior diff --git a/cmd/root.go b/cmd/root.go index 3070ac3..dd641d8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -140,6 +140,7 @@ Examples: - Business system 403: if the upstream body returns a business code such as bk_error_code 9900403 (IAM permission error), ask to apply business permission instead of API permission`, + Annotations: map[string]string{"build_version": buildInfo.Version}, SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/system/internal/monitorapi/alert_scope.go b/cmd/system/internal/monitorapi/alert_scope.go new file mode 100644 index 0000000..0213b93 --- /dev/null +++ b/cmd/system/internal/monitorapi/alert_scope.go @@ -0,0 +1,72 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +// Package monitorapi calls individual Monitor HTTP APIs through the shared request executor. +// The server remains the source of truth for tool contracts, resource ownership and permissions. +package monitorapi + +import ( + "fmt" + "maps" + "strings" +) + +// alertRequestArgs derives native search scope after Profiles and flags resolve. +func alertRequestArgs(tool string, args map[string]any) (map[string]any, error) { + if tool != "list_alerts" && tool != "get_alert_top_n" { + return args, nil + } + biz := strings.TrimSpace(fmt.Sprint(args["bk_biz_id"])) + if args["bk_biz_id"] == nil || biz == "" { + return nil, InputError("missing_arguments", "告警查询需要明确的 bk_biz_id。") + } + if provided, exists := args["bk_biz_ids"]; exists { + var ids []string + switch values := provided.(type) { + case []any: + for _, value := range values { + ids = append(ids, strings.TrimSpace(fmt.Sprint(value))) + } + case []string: + ids = values + } + if len(ids) != 1 || ids[0] != biz { + return nil, InputError("invalid_arguments", "bk_biz_ids 必须与最终 bk_biz_id 一致且只含一个业务;请移除冲突值。") + } + } + result := maps.Clone(args) + result["bk_biz_ids"] = []string{biz} + return result, nil +} + +// alertListScopeValid checks query scope; it never grants permission or switches +// business based on a response. Unexpected rows must not reach final output. +func alertListScopeValid(data, biz any) (listValid, scopeValid bool) { + rows, ok := Map(data)["alerts"].([]any) + if !ok { + return false, false + } + expected := strings.TrimSpace(fmt.Sprint(biz)) + for _, row := range rows { + value := Map(row)["bk_biz_id"] + if value == nil || strings.TrimSpace(fmt.Sprint(value)) != expected { + return true, false + } + } + return true, true +} diff --git a/cmd/system/internal/monitorapi/client.go b/cmd/system/internal/monitorapi/client.go new file mode 100644 index 0000000..f116a21 --- /dev/null +++ b/cmd/system/internal/monitorapi/client.go @@ -0,0 +1,588 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +// Package monitorapi calls individual Monitor HTTP APIs through the shared request executor. +// The server remains the source of truth for tool contracts, resource ownership and permissions. +package monitorapi + +import ( + "errors" + "fmt" + "maps" + "net/http" + "slices" + "sort" + "strconv" + "strings" + "time" + + json "github.com/goccy/go-json" + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/internal/output" + syslib "github.com/TencentBlueKing/bk-cli/internal/system" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +const ( + gatewayName = "bk-monitor" + requestIDHeader = "X-Bkapi-Request-Id" + maxWindow = 24 * time.Hour +) + +// Diagnostic endpoints use the same user-authenticated gateway policy as the tool APIs. +var authConfig = &syslib.AuthConfig{UserVerifiedRequired: true, ResourcePermissionRequired: true} + +var ( + supported = map[string]struct{}{} + commandFor = func(string) string { return "" } + nextFor = func(string, string) []string { return nil } + supportedList []string +) + +var resourceDecisions = map[string]bool{ + "needs_resource": true, "resource_not_visible": true, "resource_not_found": true, +} + +// Server judgments that describe the target rather than a broken request. +var judgments = map[string]bool{ + "needs_resource": true, "resource_not_visible": true, "resource_not_found": true, + "permission_missing": true, "permission_denied": true, "resource_blocked": true, "retry_later": true, +} + +// Endpoint is the reviewed HTTP operation for one read-only command. +type Endpoint struct { + Method string `json:"method"` + Path string `json:"path"` + QueryParams []string `json:"query_params"` + Auth syslib.AuthConfig `json:"auth"` +} + +var endpoints = map[string]Endpoint{} + +// SetEndpoints installs routes from the reviewed API catalog, never from user input. +func SetEndpoints(routes map[string]Endpoint) { endpoints = routes } + +// Runtime adds invocation-scoped request options without changing shared context or credentials. +type Runtime struct { + *syslib.Runtime + Stage string + Headers []string +} + +// Bind preserves options for every request made by a command, including preflight requests. +func Bind(cmd *cobra.Command, rt *syslib.Runtime) *Runtime { + stage, _ := cmd.Flags().GetString("stage") + headers, _ := cmd.Flags().GetStringArray("header") + return &Runtime{Runtime: rt, Stage: stage, Headers: headers} +} + +// Reply is the unwrapped API data plus trace metadata. +type Reply struct { + Data any + RequestID string + dryRun *output.Envelope + envelope *output.Envelope +} + +// IsDryRun reports whether the reply is a local preview without a response. +func (r *Reply) IsDryRun() bool { return r != nil && r.dryRun != nil } + +// Decision is a stable, user-facing translation of a local or server judgment. +type Decision struct { + Code string + Message string + ExitCode int + RequestID string + Next []string + AnyOf []map[string]string + Steps []map[string]any +} + +func (d *Decision) Error() string { return d.Code + ": " + d.Message } + +// InputError reports a locally fixable input problem. +func InputError(code, message string, next ...string) *Decision { + return &Decision{Code: code, Message: message, ExitCode: output.ExitCodeUserError, Next: next} +} + +// SetCatalog installs the reviewed read-only tool list and command hints owned by the monitor system. +func SetCatalog(tools []string, command func(string) string, next func(string, string) []string) { + supported = make(map[string]struct{}, len(tools)) + supportedList = append([]string(nil), tools...) + for _, name := range tools { + supported[name] = struct{}{} + } + commandFor, nextFor = command, next +} + +// Supported reports whether a read-only tool is in the reviewed catalog. +func Supported(tool string) bool { _, ok := supported[tool]; return ok } + +// SupportedTools returns accepted read-only tool names. +func SupportedTools() []string { return append([]string(nil), supportedList...) } + +// Command returns the CLI command for a tool, if one exists. +func Command(tool string) string { return commandFor(tool) } + +// NextCommands returns commands that produce the identifiers required by a tool. +func NextCommands(tool, biz string) []string { return nextFor(tool, biz) } + +// Call requests an explicit diagnostic API; business commands use their own Endpoint. +func Call(rt *Runtime, name string, body map[string]any) (*Reply, error) { + switch name { + case "lookup_tool", "lookup_tool_schema", "lookup_permissions", "lookup_metadata": + return request(rt, Endpoint{Method: "POST", Path: "/mcp/" + name + "/", Auth: *authConfig}, body) + default: + return nil, InputError("tool_not_allowed", "未知的诊断 API。") + } +} + +func request(rt *Runtime, endpoint Endpoint, args map[string]any) (*Reply, error) { + query, body := map[string]any{}, map[string]any{} + for key, value := range args { + inQuery := endpoint.Method == http.MethodGet || slices.Contains(endpoint.QueryParams, key) + if inQuery { + query[key] = value + } else { + body[key] = value + } + } + spec := syslib.RequestSpec{ + GatewayName: gatewayName, Method: endpoint.Method, + Path: endpoint.Path, Stage: rt.Stage, Headers: rt.Headers, AuthConfig: &endpoint.Auth, + } + var err error + if len(query) > 0 { + spec.ParamsJSON, err = systemcmd.MarshalJSON(query) + if err != nil { + return nil, err + } + } + if len(body) > 0 || endpoint.Method != http.MethodGet { + spec.BodyJSON, err = systemcmd.MarshalJSON(body) + if err != nil { + return nil, err + } + } + result, err := syslib.ExecuteRequest(rt.Runtime, spec) + if err != nil { + return nil, err + } + env := result.Envelope + if err := systemcmd.EnsureEnvelope(endpoint.Path, env); err != nil { + return nil, err + } + if env.DryRun { + return &Reply{dryRun: env}, nil + } + rid := env.Headers[requestIDHeader] + wrapped, _ := env.Data.(map[string]any) + if !env.OK || wrapped == nil || wrapped["result"] != true { + return nil, classify(env.Status, wrapped, rid) + } + if Map(wrapped["meta"])["truncated"] == true { + return nil, &Decision{ + Code: "partial_result", + Message: "服务端标记结果被截断;请缩小查询范围。", + ExitCode: output.ExitCodeSystemError, + RequestID: rid, + } + } + return &Reply{Data: wrapped["data"], RequestID: rid, envelope: env}, nil +} + +// Execute calls the selected tool's own HTTP operation. No generic execute dispatcher is used. +func Execute(rt *Runtime, tool string, args map[string]any) (*Reply, error) { + endpoint, found := endpoints[tool] + if !Supported(tool) || !found { + return nil, InputError("tool_not_allowed", "该工具没有经过审查的只读 API 路由。") + } + args, err := alertRequestArgs(tool, args) + if err != nil { + return nil, err + } + reply, err := request(rt, endpoint, args) + if err != nil { + var decision *Decision + if errors.As(err, &decision) && resourceDecisions[decision.Code] { + decision.Next = NextCommands(tool, fmt.Sprint(args["bk_biz_id"])) + } + if errors.As(err, &decision) && + (decision.Code == "permission_missing" || decision.Code == "permission_denied") { + decision.Next = []string{ + fmt.Sprintf( + "bk-cli monitor permission apply --bk_biz_id %v --tool %s", + args["bk_biz_id"], + tool, + ), + } + } + return nil, err + } + if reply.IsDryRun() { + return reply, nil + } + fail := func(code, message string) (*Reply, error) { + return nil, &Decision{ + Code: code, + Message: message, + ExitCode: output.ExitCodeSystemError, + RequestID: reply.RequestID, + } + } + if payload, ok := reply.Data.(map[string]any); ok { + switch { + case payload["result"] == false: + return fail("business_error", "底层业务返回失败,不能当作空结果。") + case payload["is_partial"] == true || Map(payload["meta"])["truncated"] == true: + return fail("partial_result", "上游只返回部分结果;请缩小查询范围。") + case tool == "execute_range_query" && payload["status"] == "error": + return fail("query_error", "指标查询引擎返回错误;检查 PromQL。") + } + } + if tool == "list_alerts" { + valid, scoped := alertListScopeValid(reply.Data, args["bk_biz_id"]) + if !valid { + return fail("invalid_response", "告警查询没有返回有效的 alerts 列表;请核对接口契约。") + } + if !scoped { + return fail("response_scope_mismatch", "告警列表的业务归属与请求不一致或无法验证;结果未输出,请核对接口和所选业务。") + } + } + if tool == "find_relations" || tool == "find_relations_range" { + rows := List(Map(reply.Data)["data"]) + if len(rows) == 0 || len(rows) != len(List(args["query_list"])) { + return fail("partial_result", "关联查询没有返回与请求数量一致的子项。") + } + for _, row := range rows { + if fmt.Sprint(Map(row)["code"]) != strconv.Itoa(http.StatusOK) { + message, _ := Map(row)["message"].(string) + if strings.Contains(message, "binding lookup failed") && + strings.Contains(message, "no usable SurrealDB result table route") { + return fail( + "relation_route_unavailable", + "目标业务没有可用的关联存储路由;请由平台管理员检查关联数据接入与路由配置。", + ) + } + return fail("relation_query_failed", "关联查询存在失败子项;根据请求 ID 排查。") + } + } + } + return reply, nil +} + +// StateDecision translates a Unified permission state. requires_resource is neither denial nor grant. +func StateDecision(state map[string]any) *Decision { + switch state["state"] { + case "granted", "exempt": + return nil + case "requires_resource": + return &Decision{ + Code: "needs_resource", + Message: "还缺具体资源(例如索引集或指标表)。这不是无权,也不是已授权。", + ExitCode: output.ExitCodeUserError, + } + case "missing": + return &Decision{ + Code: "permission_missing", + Message: "服务端明确判定无权:下列权限满足其一即可。CLI 不会自动申请。", + ExitCode: output.ExitCodeUserError, + AnyOf: permissionTargets(state), + } + } + return &Decision{ + Code: "permission_not_granted", + Message: "服务端没有给出明确授权,已停止。", + ExitCode: output.ExitCodeUserError, + } +} + +func permissionTargets(state map[string]any) []map[string]string { + var targets []map[string]string + for _, key := range []string{"legacy_permission", "fallback_permission"} { + item, _ := state[key].(map[string]any) + if item["action_id"] == nil { + continue + } + target := map[string]string{} + for _, name := range []string{"system_id", "action_id", "resource_type"} { + if value, ok := item[name].(string); ok && value != "" { + target[name] = value + } + } + if url, ok := item["apply_url"].(string); ok && + (strings.HasPrefix(url, "https://") || strings.HasPrefix(url, "http://")) { + target["apply_url"] = url + } + targets = append(targets, target) + } + return targets +} + +type rule struct { + needles []string + code string + message string + exit int +} + +// Upstream text is matched only to choose a stable code; it is never echoed. +var rules = []rule{ + { + []string{"查询结果过大"}, + "response_too_large", + "查询结果超过服务端响应限制;请缩小范围或减少返回字段。事件目录仅需发现来源时可设置 --return_dimensions=false。", + output.ExitCodeUserError, + }, + { + []string{"access_token expired", "access_token is invalid"}, + "auth_failed", + "登录凭据无效或已过期;请重新登录 bk-cli auth login。", + output.ExitCodeUserError, + }, + { + []string{"verified gateway user", "tenant does not match", "authenticated tenant"}, + "identity_unverified", + "网关用户或租户身份未通过校验;检查登录凭据与环境。", + output.ExitCodeUserError, + }, + { + []string{"incomplete catalog metadata"}, + "resource_blocked", + "服务端资源元数据不完整或不受支持。这不是无权;需要修服务端目录,CLI 不绕过。", + output.ExitCodeSystemError, + }, + { + []string{"permission service is unavailable", "metadata is unavailable", "did not return a"}, + "retry_later", + "权限或资源元数据服务暂不可用:未授权,也不代表无权。稍后重试。", + output.ExitCodeSystemError, + }, + { + []string{"not uniquely visible", "does not belong", "does not match the requested business"}, + "resource_not_visible", + "该资源不在目标业务的可见目录中。请从列表命令重新选择,不要换业务或猜 ID。", + output.ExitCodeUserError, + }, + { + []string{"does not exist", "no logindexset matches the given query"}, + "resource_not_found", + "资源不存在。请从列表命令返回值中选择。", + output.ExitCodeUserError, + }, + { + []string{"3626305"}, + "extract_policy_missing", + "当前业务还没有可用的日志提取策略;请由管理员在日志平台的日志提取配置中添加策略。", + output.ExitCodeUserError, + }, + {[]string{"tool_args"}, "invalid_arguments", "参数不符合服务端工具契约;可加 --dry-run 查看将发送的请求。", output.ExitCodeUserError}, +} + +func classify(status int, body map[string]any, rid string) *Decision { + if detail, ok := body["detail"].(map[string]any); ok && detail["state"] != nil { + if decision := StateDecision(detail); decision != nil { + decision.RequestID = rid + return decision + } + } + code := fmt.Sprint(body["code"]) + if code == "3324002" { + return &Decision{ + Code: "resource_not_found", Message: "指定业务下未找到该告警或事件;请从同一业务的列表重新选择。", + ExitCode: output.ExitCodeUserError, RequestID: rid, + } + } + raw, _ := json.Marshal(body) + text := strings.ToLower(string(raw)) + for _, item := range rules { + for _, needle := range item.needles { + if strings.Contains(text, needle) { + return &Decision{ + Code: item.code, + Message: item.message, + ExitCode: item.exit, + RequestID: rid, + } + } + } + } + switch { + case status == http.StatusNotFound: + return &Decision{ + Code: "api_not_found", + Message: "请求路径返回 404;请核对 stage、网关前台路径、后端映射和服务路由/版本。", + ExitCode: output.ExitCodeSystemError, + RequestID: rid, + } + case code == "503" || status == 503: + return &Decision{ + Code: "retry_later", + Message: "服务端暂不可用;本次未取得成功结果,不能据此判断是否有权限。请保留请求 ID 排查。", + ExitCode: output.ExitCodeSystemError, + RequestID: rid, + } + case code == "403" || status == 401 || status == 403: + return &Decision{ + Code: "permission_denied", + Message: "请求被拒绝。运行 bk-cli monitor permission check 查看服务端判定;CLI 不自动申请权限。", + ExitCode: output.ExitCodeUserError, + RequestID: rid, + } + } + return &Decision{ + Code: "business_error", + Message: "服务端没有返回成功;根据请求 ID 排查,不能当作空结果。", + ExitCode: output.ExitCodeSystemError, + RequestID: rid, + } +} + +// PrintFailure prints a Decision as a structured stderr envelope; other errors were printed by shared layers. +func PrintFailure(cmd *cobra.Command, err error, details ...map[string]any) error { + var decision *Decision + if !errors.As(err, &decision) { + return err + } + data := map[string]any{"decision": decision.Code} + for _, detail := range details { + maps.Copy(data, detail) + } + if decision.RequestID != "" { + data["request_id"] = decision.RequestID + } + if len(decision.Next) > 0 { + data["next"] = decision.Next + } + if len(decision.AnyOf) > 0 { + data["any_of_permissions"] = decision.AnyOf + } + if len(decision.Steps) > 0 { + data["steps"] = decision.Steps + } + _ = (&output.Envelope{ + OK: false, + Data: data, + Error: &output.ErrorDetail{Code: decision.Code, Message: decision.Message}, + }).WriteJSON(cmd.ErrOrStderr()) + return &output.CLIError{ExitCode: decision.ExitCode, Code: decision.Code, Message: decision.Message} +} + +// Print writes a compact success envelope, or the dry-run request preview. +func Print(cmd *cobra.Command, reply *Reply, data any) error { + return output.WriteCommandEnvelope(cmd, reply.Envelope(data)) +} + +// Envelope returns the response or unchanged request preview for local decoration. +func (r *Reply) Envelope(data any) *output.Envelope { + if r.envelope != nil { + env := *r.envelope + env.Data = data + return &env + } + if r.IsDryRun() { + return r.dryRun + } + env := &output.Envelope{OK: true, Data: data} + if r.RequestID != "" { + env.Headers = map[string]string{requestIDHeader: r.RequestID} + } + return env +} + +// Window converts --since into the Unix-second API strings. +func Window(since string, now time.Time) (string, string, error) { + duration, err := time.ParseDuration(since) + if err != nil || duration <= 0 || duration > maxWindow { + return "", "", InputError("invalid_since", "--since 使用 15m、1h 这类格式,且不超过 24h。") + } + end := now.Unix() + return strconv.FormatInt(end-int64(duration.Seconds()), 10), strconv.FormatInt(end, 10), nil +} + +// RequireBiz rejects a missing business with the command that discovers it. +func RequireBiz(biz string) error { + if strings.TrimSpace(biz) == "" { + return InputError( + "needs_business", + "需要 --bk_biz_id;不知道时先按名称查询。", + "bk-cli monitor space search_spaces --space_name <名称>", + ) + } + return nil +} + +// Run resolves the shared runtime and prints Decisions as structured errors. +func Run( + deps systemcmd.BuildDeps, + fn func(*cobra.Command, *Runtime) error, +) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, _ []string) error { + rt, err := systemcmd.ResolveRuntime(deps) + if err != nil { + return err + } + return PrintFailure(cmd, fn(cmd, Bind(cmd, rt))) + } +} + +// ResourceContext keeps only resource fields declared by the tool's own permission contract. +func ResourceContext(schema, values map[string]any) (map[string]any, []string) { + spec := Map(Map(schema["permission"])["fallback_permission"]) + conditional := Map(spec["conditional"]) + allowed := map[string]bool{} + keys := []any{spec["resource_arg"], spec["target_arg"], conditional["arg"]} + keys = append(keys, List(conditional["all_args"])...) + for _, key := range keys { + if name, ok := key.(string); ok && name != "" && name != "bk_biz_id" { + allowed[name] = true + } + } + if spec["resource_type"] == "indices" { + allowed["target_type"] = true + } + context, unused := map[string]any{}, []string{} + for key, value := range values { + if allowed[key] { + context[key] = value + } else { + unused = append(unused, key) + } + } + sort.Strings(unused) + return context, unused +} + +// IsResourceDecision reports whether the next step is to choose a real resource. +func IsResourceDecision(code string) bool { return resourceDecisions[code] } + +// IsJudgment reports whether a decision is a server judgment that check should report. +func IsJudgment(err error) (*Decision, bool) { + var decision *Decision + if errors.As(err, &decision) && judgments[decision.Code] { + return decision, true + } + return nil, false +} + +// Map returns value as a JSON object, or nil. +func Map(value any) map[string]any { m, _ := value.(map[string]any); return m } + +// List returns value as a JSON array, or nil. +func List(value any) []any { l, _ := value.([]any); return l } diff --git a/cmd/system/internal/monitorapi/client_test.go b/cmd/system/internal/monitorapi/client_test.go new file mode 100644 index 0000000..08bc23e --- /dev/null +++ b/cmd/system/internal/monitorapi/client_test.go @@ -0,0 +1,177 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitorapi + +import ( + "errors" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Monitor API decisions", func() { + It("keeps requires_resource separate from permission denial", func() { + decision := StateDecision(map[string]any{"state": "requires_resource"}) + Expect(decision.Code).To(Equal("needs_resource")) + Expect(decision.AnyOf).To(BeEmpty()) + }) + + It("lists either permission and drops unsafe apply links", func() { + decision := StateDecision(map[string]any{ + "state": "missing", + "legacy_permission": map[string]any{ + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "apply_url": "https://iam.invalid/apply", + }, + "fallback_permission": map[string]any{ + "system_id": "bk_log_search", + "action_id": "view_business_v2", + "apply_url": "javascript:alert(1)", + }, + }) + Expect(decision.Code).To(Equal("permission_missing")) + Expect(decision.AnyOf).To(HaveLen(2)) + Expect(decision.AnyOf[0]).To(HaveKey("apply_url")) + Expect(decision.AnyOf[1]).NotTo(HaveKey("apply_url")) + }) + + DescribeTable( + "classifies server failures without echoing upstream text", + func(status int, body map[string]any, code string) { + decision := classify(status, body, "request-1") + Expect(decision.Code).To(Equal(code)) + Expect(decision.RequestID).To(Equal("request-1")) + Expect(decision.Message).NotTo(ContainSubstring("SECRET")) + }, + Entry( + "metadata block", + 200, + map[string]any{"detail": []any{"incomplete catalog metadata SECRET"}}, + "resource_blocked", + ), + Entry( + "invisible resource", + 200, + map[string]any{"message": "not uniquely visible SECRET"}, + "resource_not_visible", + ), + Entry( + "missing resource", + 200, + map[string]any{"message": "table does not exist SECRET"}, + "resource_not_found", + ), + Entry( + "native missing index", + 200, + map[string]any{"message": "No LogIndexSet matches the given query. SECRET"}, + "resource_not_found", + ), + Entry("unpublished API", 404, nil, "api_not_found"), + Entry( + "native missing alert", + 200, + map[string]any{"code": 3324002, "message": "SECRET"}, + "resource_not_found", + ), + Entry( + "native missing event", + 404, + map[string]any{"code": "3324002", "message": "SECRET"}, + "resource_not_found", + ), + Entry("IAM unavailable", 200, map[string]any{"code": 503, "message": "SECRET"}, "retry_later"), + Entry( + "bounded result", + 400, + map[string]any{"data": map[string]any{"message": "查询结果过大 SECRET"}}, + "response_too_large", + ), + Entry( + "structured denial", + 200, + map[string]any{"code": 403, "detail": map[string]any{"state": "missing"}}, + "permission_missing", + ), + Entry( + "schema rejection", + 200, + map[string]any{"detail": map[string]any{"tool_args": "SECRET"}}, + "invalid_arguments", + ), + Entry( + "log extraction policy missing", + 200, + map[string]any{"message": "code 3626305 SECRET"}, + "extract_policy_missing", + ), + Entry("unknown business failure", 200, map[string]any{"message": "SECRET"}, "business_error"), + ) + + It("sends only resource fields declared by the permission contract", func() { + schema := map[string]any{"permission": map[string]any{"fallback_permission": map[string]any{ + "resource_type": "indices", "resource_arg": "index_set_id", + }}} + context, unused := ResourceContext( + schema, + map[string]any{"index_set_id": 1, "target_type": "index_set", "table_id": "x.y"}, + ) + Expect(context).To(Equal(map[string]any{"index_set_id": 1, "target_type": "index_set"})) + Expect(unused).To(Equal([]string{"table_id"})) + }) + + It("uses the installed catalog for allowlist and next commands", func() { + SetCatalog( + []string{"get_index_set_fields"}, + func(name string) string { return "bk-cli monitor log " + name }, + func(string, string) []string { + return []string{"bk-cli monitor log list_index_sets --bk_biz_id 100100"} + }, + ) + Expect(Supported("get_index_set_fields")).To(BeTrue()) + Expect(Supported("create_dashboard")).To(BeFalse()) + Expect( + NextCommands("get_index_set_fields", "100100"), + ).To( + Equal([]string{"bk-cli monitor log list_index_sets --bk_biz_id 100100"}), + ) + Expect(IsResourceDecision("resource_not_found")).To(BeTrue()) + Expect(IsResourceDecision("permission_missing")).To(BeFalse()) + }) + + It("only reports server judgments as check results", func() { + _, ok := IsJudgment(&Decision{Code: "resource_blocked"}) + Expect(ok).To(BeTrue()) + _, ok = IsJudgment(&Decision{Code: "business_error"}) + Expect(ok).To(BeFalse()) + _, ok = IsJudgment(errors.New("network")) + Expect(ok).To(BeFalse()) + }) + + It("bounds relative windows", func() { + start, end, err := Window("15m", time.Unix(1800000000, 0)) + Expect(err).NotTo(HaveOccurred()) + Expect(start).To(Equal("1799999100")) + Expect(end).To(Equal("1800000000")) + _, _, err = Window("25h", time.Unix(1800000000, 0)) + Expect(err).To(MatchError(ContainSubstring("invalid_since"))) + }) +}) diff --git a/cmd/system/internal/monitorapi/monitorapi_suite_test.go b/cmd/system/internal/monitorapi/monitorapi_suite_test.go new file mode 100644 index 0000000..788f4da --- /dev/null +++ b/cmd/system/internal/monitorapi/monitorapi_suite_test.go @@ -0,0 +1,31 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitorapi + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMonitorAPI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Monitor HTTP API Suite") +} diff --git a/cmd/system/monitor.go b/cmd/system/monitor.go new file mode 100644 index 0000000..560b4ba --- /dev/null +++ b/cmd/system/monitor.go @@ -0,0 +1,28 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package system + +import ( + monitorsystem "github.com/TencentBlueKing/bk-cli/cmd/system/monitor" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +func newMonitorSystemSpec() systemcmd.SystemSpec { + return monitorsystem.NewSystemSpec() +} diff --git a/cmd/system/monitor/alert_scope_test.go b/cmd/system/monitor/alert_scope_test.go new file mode 100644 index 0000000..8fb10fd --- /dev/null +++ b/cmd/system/monitor/alert_scope_test.go @@ -0,0 +1,205 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +// Package monitorapi calls individual Monitor HTTP APIs through the shared request executor. +// The server remains the source of truth for tool contracts, resource ownership and permissions. +package monitor + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + + json "github.com/goccy/go-json" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + systemtest "github.com/TencentBlueKing/bk-cli/cmd/system/testutil" + "github.com/TencentBlueKing/bk-cli/internal/output" +) + +var _ = Describe("native alert business scope", func() { + var dir string + BeforeEach(func() { + dir = GinkgoT().TempDir() + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", dir) + GinkgoT().Setenv("BK_CLI_MONITOR_PROFILE_FILE", "") + }) + + DescribeTable( + "derives native scope after body, flags and Profile selection", + func(tool, body, biz string, profile bool) { + Expect(systemtest.SetupTestContext("https://gateway.example.test/api")).To(Succeed()) + cmd := toolCommand(tool, true) + setFlags(cmd, "since", "1h") + if tool == "get_alert_top_n" { + setFlags(cmd, "fields", "severity") + } + if body != "" { + setFlags(cmd, "body", body) + } + if biz != "" { + setFlags(cmd, "bk_biz_id", biz) + } + if profile { + file := filepath.Join(dir, "profile.yaml") + Expect(os.WriteFile(file, []byte(testProfiles), 0o600)).To(Succeed()) + setFlags(cmd, "profile", "worker", "profile_file", file) + } + args := dryRunArgs(cmd) + expected := "100100" + if biz == "9007199254740993" || body == `{"bk_biz_id":9007199254740993}` { + expected = "9007199254740993" + } + Expect(args["bk_biz_ids"]).To(Equal([]any{expected})) + }, + Entry("named override", "list_alerts", `{"bk_biz_id":"900001"}`, "100100", false), + Entry("matching raw array", "list_alerts", `{"bk_biz_id":"100100","bk_biz_ids":["100100"]}`, "", false), + Entry( + "numeric matching array", + "list_alerts", + `{"bk_biz_id":"100100","bk_biz_ids":[100100]}`, + "", + false, + ), + Entry("large named identifier", "list_alerts", "", "9007199254740993", false), + Entry("large body identifier", "list_alerts", `{"bk_biz_id":9007199254740993}`, "", false), + Entry("Profile scope", "list_alerts", "", "", true), + Entry("TopN scope", "get_alert_top_n", "", "100100", false), + ) + + DescribeTable("rejects conflicting backend scope before making a request", + func(raw string) { + var calls atomic.Int32 + server := httptest.NewServer( + http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls.Add(1) }), + ) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := toolCommand("list_alerts", false) + setFlags(cmd, "body", `{"bk_biz_id":"100100","bk_biz_ids":`+raw+`}`, "since", "1h") + _, err := runProfileCommand(cmd) + Expect(err).To(HaveOccurred()) + Expect(err.(*output.CLIError).Code).To(Equal("invalid_arguments")) + Expect(calls.Load()).To(BeZero()) + }, + Entry("foreign business", `["100200"]`), + Entry("multiple businesses", `["100100","100200"]`), + Entry("duplicates", `["100100","100100"]`), + Entry("empty array", `[]`), + Entry("scalar", `"100100"`), + Entry("null", `null`), + ) + + It("derives scope from a V2 binding and preserves its TopN preset", func() { + Expect(systemtest.SetupTestContext("https://gateway.example.test/api")).To(Succeed()) + file := filepath.Join(dir, "v2.yaml") + raw := `version: 2 +sources: {sample-source: {context: default, bk_biz_id: "100100"}} +profiles: + sample: + components: + api: + bindings: + alerts: + module: alert + source: sample-source + tools: {get_alert_top_n: {fields: [severity]}} +` + Expect(os.WriteFile(file, []byte(raw), 0o600)).To(Succeed()) + for _, tool := range []string{"list_alerts", "get_alert_top_n"} { + cmd := toolCommand(tool, true) + setFlags(cmd, "profile", "sample", "profile_file", file, "component", "api", "since", "1h") + args := dryRunArgs(cmd) + Expect(args["bk_biz_ids"]).To(Equal([]any{"100100"})) + if tool == "get_alert_top_n" { + Expect(args["fields"]).To(Equal([]any{"severity"})) + } + } + }) + + It("sends the derived scope and follows a same-business list ID to detail", func() { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + calls.Add(1) + if r.Method == http.MethodPost { + Expect(r.URL.Path).To(HaveSuffix("/mcp/list_alerts/")) + args := readMonitorRequest(r) + Expect(args["bk_biz_ids"]).To(Equal([]any{"100100"})) + _, _ = io.WriteString( + w, + `{"result":true,"data":{"alerts":[{"id":"sample-alert","bk_biz_id":100100}]}}`, + ) + } else { + Expect(r.URL.Path).To(HaveSuffix("/mcp/get_alert_info/")) + Expect(r.URL.Query().Get("bk_biz_id")).To(Equal("100100")) + Expect(r.URL.Query().Get("id")).To(Equal("sample-alert")) + _, _ = io.WriteString(w, `{"result":true,"data":{"id":"sample-alert"}}`) + } + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := toolCommand("list_alerts", false) + setFlags(cmd, "bk_biz_id", "100100", "since", "1h") + env, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + row := env["data"].(map[string]any)["alerts"].([]any)[0].(map[string]any) + cmd = toolCommand("get_alert_info", false) + setFlags(cmd, "bk_biz_id", "100100", "id", row["id"].(string)) + _, err = runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(calls.Load()).To(Equal(int32(2))) + }) + + DescribeTable( + "withholds malformed or out-of-scope responses before jq", + func(data, code string) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Bkapi-Request-Id", "scope-request") + _, _ = io.WriteString(w, `{"result":true,"data":`+data+`}`) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := toolCommand("list_alerts", false) + setFlags(cmd, "bk_biz_id", "100100", "since", "1h", "jq", ".data") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + err := cmd.RunE(cmd, nil) + Expect(err.(*output.CLIError).Code).To(Equal(code)) + Expect(stdout.String()).To(BeEmpty()) + Expect(stderr.String()).NotTo(ContainSubstring("PRIVATE-CONTENT")) + var failure map[string]any + Expect(json.Unmarshal(stderr.Bytes(), &failure)).To(Succeed()) + Expect(failure["data"]).To(HaveKeyWithValue("request_id", "scope-request")) + }, + Entry( + "foreign business", + `{"alerts":[{"bk_biz_id":"100200","description":"PRIVATE-CONTENT"}]}`, + "response_scope_mismatch", + ), + Entry("missing business", `{"alerts":[{"description":"PRIVATE-CONTENT"}]}`, "response_scope_mismatch"), + Entry("missing list", `{}`, "invalid_response"), + Entry("invalid list", `{"alerts":null}`, "invalid_response"), + ) +}) diff --git a/cmd/system/monitor/catalog.go b/cmd/system/monitor/catalog.go new file mode 100644 index 0000000..ce6ccbb --- /dev/null +++ b/cmd/system/monitor/catalog.go @@ -0,0 +1,138 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + _ "embed" + "fmt" + "net/http" + "sort" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + + json "github.com/goccy/go-json" +) + +// tools.json is a reviewed snapshot of Monitor read-only HTTP operations. Execution still fetches no local +// authority from it: the server validates arguments, resources and permissions on every request. +// +//go:embed catalog/tools.json +var catalogJSON []byte + +type catalog struct { + Version string `json:"catalog_version"` + Tools []tool `json:"tools"` + Actions map[string]string `json:"actions"` + ResourceTypes map[string]string `json:"resource_types"` +} + +type tool struct { + Endpoint monitorapi.Endpoint `json:"endpoint"` + Module string `json:"module"` + Category string `json:"category"` + Name string `json:"name"` + Short string `json:"short"` + Capabilities []string `json:"capabilities"` + Prerequisites []string `json:"prerequisites"` + Params []param `json:"params"` + Permission permissionSpec `json:"permission"` +} + +// permissionSpec mirrors Unified MCP permission metadata. It explains which IAM actions are involved; +// it is never used as an authorization decision. +type permissionSpec struct { + Mode string `json:"mode"` + MCP *iamAction `json:"mcp,omitempty"` + Native *nativeAction `json:"native,omitempty"` +} + +type iamAction struct { + SystemID string `json:"system_id"` + ActionID string `json:"action_id"` + ResourceType string `json:"resource_type"` + ResourceArg string `json:"resource_arg,omitempty"` +} + +type nativeAction struct { + iamAction + Conditional *conditionalAction `json:"conditional,omitempty"` +} + +type conditionalAction struct { + iamAction + When string `json:"when"` +} + +type param struct { + Name string `json:"name"` + Type string `json:"type"` + Items string `json:"items,omitempty"` + Required bool `json:"required"` + Description string `json:"description"` + Enum []string `json:"enum,omitempty"` + TimeUnit string `json:"time_unit,omitempty"` +} + +type module struct { + Name string + Category string + Description string +} + +var modules = []module{ + {"space", "metadata", "Find business spaces and BCS clusters"}, + {"metric", "metrics", "Metric tables, metric fields, PromQL and SQL queries"}, + {"log", "log", "Log scenes, index sets, fields, search, context and analysis"}, + {"alert", "alert", "Alerts, alert events, targets and related strategies"}, + {"event", "event", "Event sources and event search"}, + {"apm", "apm", "APM applications, services, traces, spans and profiling"}, + {"dashboard", "dashboard", "Dashboard tree and dashboard details"}, + {"relation", "relation", "Resource relations"}, + {"alarm", "alert_handling", "Alarm strategies, shields, notice groups and action configs (read only)"}, + {"collect", "log_collection", "Log collectors, index-set groups and ETL preview (read only)"}, + {"extract", "log_extract", "Log-extraction topology, hosts, paths and task state (read only)"}, +} + +func loadCatalog() (*catalog, map[string]tool, error) { + var value catalog + if err := json.Unmarshal(catalogJSON, &value); err != nil { + return nil, nil, fmt.Errorf("invalid monitor tool catalog: %w", err) + } + byName := make(map[string]tool, len(value.Tools)) + known := map[string]struct{}{} + for _, item := range modules { + known[item.Name] = struct{}{} + } + for _, item := range value.Tools { + if item.Endpoint.Path == "" || + (item.Endpoint.Method != http.MethodGet && item.Endpoint.Method != http.MethodPost) || + item.Endpoint.Path == "/mcp/execute_tool/" { + return nil, nil, fmt.Errorf("invalid API route for %q", item.Name) + } + if _, ok := known[item.Module]; !ok { + return nil, nil, fmt.Errorf("monitor tool %q has unknown module %q", item.Name, item.Module) + } + if _, exists := byName[item.Name]; exists { + return nil, nil, fmt.Errorf("monitor tool %q is duplicated", item.Name) + } + byName[item.Name] = item + } + sort.Slice(value.Tools, func(i, j int) bool { return value.Tools[i].Name < value.Tools[j].Name }) + return &value, byName, nil +} diff --git a/cmd/system/monitor/catalog/tools.json b/cmd/system/monitor/catalog/tools.json new file mode 100644 index 0000000..665cec8 --- /dev/null +++ b/cmd/system/monitor/catalog/tools.json @@ -0,0 +1,4527 @@ +{ + "source": "bk-monitor reviewed read-only HTTP APIs and diagnostic metadata", + "catalog_version": "7891fc8fb223", + "tools": [ + { + "module": "metric", + "category": "metrics", + "name": "execute_range_query", + "short": "Run a PromQL range query", + "capabilities": [ + "query" + ], + "prerequisites": [ + "list_time_series_groups", + "list_time_series_metrics" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID 业务ID" + }, + { + "name": "end_time", + "type": "string", + "required": true, + "description": "End timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "promql", + "type": "string", + "required": true, + "description": "PromQL query statement PromQL查询语句" + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "step", + "type": "string", + "required": false, + "description": "Query resolution step interval (e.g., \"1m\", \"5m\", \"1h\") 查询分辨率步长间隔(例如:\"1m\"、\"5m\"、\"1h\")" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_metrics_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "explore_metric_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/execute_range_query/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "metric", + "category": "metrics", + "name": "execute_sql_query", + "short": "Run a BkBase SQL raw-data query", + "capabilities": [ + "query" + ], + "prerequisites": [ + "list_result_tables" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID, used to resolve space_uid for bk-unify-query routing and permission scope" + }, + { + "name": "end_time", + "type": "string", + "required": true, + "description": "End timestamp", + "time_unit": "ms" + }, + { + "name": "limit", + "type": "number", + "required": false, + "description": "Maximum number of raw records to return" + }, + { + "name": "sql", + "type": "string", + "required": true, + "description": "BkBase SQL statement" + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp", + "time_unit": "ms" + }, + { + "name": "step", + "type": "string", + "required": false, + "description": "Query step interval (e.g., \"1m\", \"5m\", \"1h\")" + }, + { + "name": "table_id", + "type": "string", + "required": true, + "description": "BkBase result table ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_metrics_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "explore_metric_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/execute_sql_query/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "metric", + "category": "metrics", + "name": "list_time_series_groups", + "short": "List all time series groups in the monitoring system", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "business ID 业务ID" + }, + { + "name": "is_platform", + "type": "boolean", + "required": false, + "description": "Whether to query platform-level data ID 是否查询公共数据", + "enum": [ + "False" + ] + }, + { + "name": "page", + "type": "string", + "required": false, + "description": "Page Number 页数" + }, + { + "name": "page_size", + "type": "string", + "required": false, + "description": "Number of items to retrieve 获取的条数" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_metrics_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "explore_metric_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/list_time_series_groups/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "metric", + "category": "metrics", + "name": "list_time_series_metrics", + "short": "Get all metrics information from a specific time series group", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "list_time_series_groups" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID 业务ID" + }, + { + "name": "table_id", + "type": "string", + "required": true, + "description": "Result table ID 结果表ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_metrics_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "explore_metric_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/list_time_series_metrics/", + "query_params": [ + "table_id", + "bk_biz_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "log", + "category": "log", + "name": "analyze_field", + "short": "Analyze field distribution in one index set", + "capabilities": [ + "analysis" + ], + "prerequisites": [ + "list_index_sets", + "get_index_set_fields" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "conditions", + "type": "object", + "required": false, + "description": "Additional filter conditions" + }, + { + "name": "end_time", + "type": "string", + "required": true, + "description": "End timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "field_name", + "type": "string", + "required": true, + "description": "Field name to analyze" + }, + { + "name": "group_by", + "type": "boolean", + "required": false, + "description": "Whether to group statistics by field values" + }, + { + "name": "index_set_id", + "type": "integer", + "required": true, + "description": "Index set ID" + }, + { + "name": "limit", + "type": "string", + "required": false, + "description": "Maximum number of results to return" + }, + { + "name": "order_by", + "type": "string", + "required": false, + "description": "Sort order for results", + "enum": [ + "value", + "time" + ] + }, + { + "name": "query_string", + "type": "string", + "required": false, + "description": "Log search query string to filter logs before analysis" + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "step", + "type": "string", + "required": false, + "description": "Time step interval for time series analysis" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "search_log_v2", + "resource_type": "indices", + "resource_arg": "index_set_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/analyze_field/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "log", + "category": "log", + "name": "get_index_set_fields", + "short": "List fields of one index set", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "list_index_sets" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "index_set_id", + "type": "integer", + "required": true, + "description": "Index set ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "search_log_v2", + "resource_type": "indices", + "resource_arg": "index_set_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_index_set_fields/", + "query_params": [ + "bk_biz_id", + "index_set_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "log", + "category": "log", + "name": "get_scene_log_fields", + "short": "List fields for scene routing conditions", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "list_log_scenes", + "list_scene_dimension_values" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "table_id_conditions", + "type": "array", + "required": true, + "description": "Result-table routing conditions", + "items": "array" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_scene_log_fields/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "log", + "category": "log", + "name": "list_index_sets", + "short": "List all available log index sets in BlueKing Monitor", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/list_index_sets/", + "query_params": [ + "bk_biz_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "log", + "category": "log", + "name": "list_log_scenes", + "short": "List log scenes available to a business and their dimension definitions", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/list_log_scenes/", + "query_params": [ + "bk_biz_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "log", + "category": "log", + "name": "list_scene_dimension_values", + "short": "List valid values for a dynamic scene routing dimension", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "list_log_scenes" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "dimension_key", + "type": "string", + "required": true, + "description": "Dynamic dimension key returned by list_log_scenes, such as cluster_id" + }, + { + "name": "filters", + "type": "array", + "required": false, + "description": "Previously selected routing dimensions for cascading lookup", + "items": "object" + }, + { + "name": "scene", + "type": "string", + "required": true, + "description": "Scene identifier returned by list_log_scenes, such as k8s or bk_paas" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/list_scene_dimension_values/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "log", + "category": "log", + "name": "search_index_set_context", + "short": "Show logs before and after one search hit", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "search_logs" + ], + "params": [ + { + "name": "begin", + "type": "string", + "required": true, + "description": "Offset from the anchor" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "dtEventTimeStamp", + "type": "string", + "required": true, + "description": "Log timestamp for context positioning" + }, + { + "name": "gseIndex", + "type": "string", + "required": true, + "description": "GSE packet sequence number for log positioning" + }, + { + "name": "index_set_id", + "type": "integer", + "required": true, + "description": "Index set ID" + }, + { + "name": "iterationIndex", + "type": "string", + "required": false, + "description": "GSE packet iteration index" + }, + { + "name": "path", + "type": "string", + "required": false, + "description": "Log file path as returned by search_logs, if needed for positioning" + }, + { + "name": "serverIp", + "type": "string", + "required": true, + "description": "Use the exact serverIp value returned by search_logs, including for BCS-collected logs" + }, + { + "name": "size", + "type": "string", + "required": false, + "description": "Per-direction record limit, default 50" + }, + { + "name": "zero", + "type": "boolean", + "required": true, + "description": "Whether to query both directions" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "search_log_v2", + "resource_type": "indices", + "resource_arg": "index_set_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/search_index_set_context/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "log", + "category": "log", + "name": "search_log_clustering_pattern", + "short": "Find log clustering patterns in one index set", + "capabilities": [ + "analysis" + ], + "prerequisites": [ + "list_index_sets" + ], + "params": [ + { + "name": "begin", + "type": "integer", + "required": false, + "description": "Offset number for pagination" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "end_time", + "type": "string", + "required": true, + "description": "End timestamp in milliseconds (Unix timestamp as string)", + "time_unit": "ms" + }, + { + "name": "group_by", + "type": "array", + "required": false, + "description": "Optional field names used to group clustering patterns", + "items": "string" + }, + { + "name": "index_set_id", + "type": "integer", + "required": true, + "description": "Index set ID" + }, + { + "name": "keyword", + "type": "string", + "required": false, + "description": "Search keyword for filtering patterns" + }, + { + "name": "pattern_level", + "type": "string", + "required": true, + "description": "Clustering sensitivity level", + "enum": [ + "01", + "03", + "05", + "07", + "09" + ] + }, + { + "name": "show_new_pattern", + "type": "boolean", + "required": true, + "description": "Whether to show only newly discovered patterns" + }, + { + "name": "size", + "type": "integer", + "required": false, + "description": "Maximum number of pattern records to return" + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp in milliseconds (Unix timestamp as string)", + "time_unit": "ms" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "search_log_v2", + "resource_type": "indices", + "resource_arg": "index_set_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/search_log_clustering_pattern/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "log", + "category": "log", + "name": "search_logs", + "short": "Search logs by scene or index set", + "capabilities": [ + "query" + ], + "prerequisites": [ + "list_index_sets", + "get_index_set_fields" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "conditions", + "type": "object", + "required": false, + "description": "Structured filter conditions for target_type=index_set only, combined with query_string using AND relation" + }, + { + "name": "end_time", + "type": "string", + "required": true, + "description": "End timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "index_set_id", + "type": "integer", + "required": false, + "description": "Index set ID" + }, + { + "name": "keep_columns", + "type": "array", + "required": false, + "description": "Output fields to keep", + "items": "string" + }, + { + "name": "limit", + "type": "integer", + "required": false, + "description": "Maximum number of log records to return" + }, + { + "name": "offset", + "type": "integer", + "required": false, + "description": "Offset for pagination, used with limit to fetch subsequent pages (e.g" + }, + { + "name": "order_by", + "type": "array", + "required": false, + "description": "Result sort fields", + "items": "string" + }, + { + "name": "query_string", + "type": "string", + "required": false, + "description": "Log search query string" + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "table_id_conditions", + "type": "array", + "required": false, + "description": "Result-table routing conditions required when target_type=scene", + "items": "array" + }, + { + "name": "target_type", + "type": "string", + "required": false, + "description": "Search target type", + "enum": [ + "index_set", + "scene" + ] + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "search_log_v2", + "resource_type": "indices", + "resource_arg": "index_set_id", + "conditional": { + "when": "target_type=scene", + "system_id": "bk_log_search", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/search_logs/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_alert_event_tag_detail", + "short": "Get alert event bubble detail at a specific time point", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "get_alert_event_ts" + ], + "params": [ + { + "name": "alert_id", + "type": "string", + "required": true, + "description": "Alert ID" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "interval", + "type": "string", + "required": false, + "description": "Aggregation interval in seconds" + }, + { + "name": "limit", + "type": "string", + "required": false, + "description": "Maximum number of event samples to return" + }, + { + "name": "sources", + "type": "array", + "required": false, + "description": "Event source filter", + "items": "string", + "enum": [ + "BCS", + "HOST", + "BKCI", + "DEFAULT" + ] + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp (seconds)", + "time_unit": "s" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_alert_event_tag_detail/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_alert_event_ts", + "short": "Get alert related event time series data for trend visualization", + "capabilities": [ + "detail", + "analysis" + ], + "prerequisites": [ + "list_alerts", + "get_alert_info" + ], + "params": [ + { + "name": "alert_id", + "type": "string", + "required": true, + "description": "Alert ID" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "end_time", + "type": "string", + "required": false, + "description": "Custom end timestamp (seconds)", + "time_unit": "s" + }, + { + "name": "interval", + "type": "string", + "required": false, + "description": "Time bucket interval in seconds" + }, + { + "name": "sources", + "type": "array", + "required": false, + "description": "Event source filter", + "items": "string", + "enum": [ + "BCS", + "HOST", + "BKCI", + "DEFAULT" + ] + }, + { + "name": "start_time", + "type": "string", + "required": false, + "description": "Custom start timestamp (seconds)", + "time_unit": "s" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_alert_event_ts/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_alert_events", + "short": "Get alert related events list", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "list_alerts", + "get_alert_info" + ], + "params": [ + { + "name": "alert_id", + "type": "string", + "required": true, + "description": "Alert ID" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "limit", + "type": "string", + "required": false, + "description": "Maximum number of events to return" + }, + { + "name": "offset", + "type": "string", + "required": false, + "description": "Pagination offset" + }, + { + "name": "sources", + "type": "array", + "required": false, + "description": "Event source filter", + "items": "string", + "enum": [ + "BCS", + "HOST", + "BKCI", + "DEFAULT" + ] + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_alert_events/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_alert_host_target", + "short": "Get host targets for an alert", + "capabilities": [ + "relation" + ], + "prerequisites": [ + "list_alerts", + "get_alert_info" + ], + "params": [ + { + "name": "alert_id", + "type": "string", + "required": true, + "description": "Alert ID" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_alert_host_target/", + "query_params": [ + "bk_biz_id", + "alert_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_alert_info", + "short": "Get alert details", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "list_alerts" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "id", + "type": "string", + "required": true, + "description": "Alert ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_alert_info/", + "query_params": [ + "bk_biz_id", + "id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_alert_k8s_target", + "short": "Get Kubernetes targets for an alert", + "capabilities": [ + "relation" + ], + "prerequisites": [ + "list_alerts", + "get_alert_info" + ], + "params": [ + { + "name": "alert_id", + "type": "string", + "required": true, + "description": "Alert ID" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_alert_k8s_target/", + "query_params": [ + "bk_biz_id", + "alert_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_alert_log_relations", + "short": "Get log targets related to an alert, including queryable log index set information", + "capabilities": [ + "relation" + ], + "prerequisites": [ + "list_alerts", + "get_alert_info" + ], + "params": [ + { + "name": "alert_id", + "type": "string", + "required": true, + "description": "Alert ID" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_alert_log_relations/", + "query_params": [ + "bk_biz_id", + "alert_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_alert_top_n", + "short": "Analyze alert events by fields and return Top N aggregation results", + "capabilities": [ + "analysis" + ], + "prerequisites": [ + "list_alerts" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "conditions", + "type": "array", + "required": false, + "description": "Advanced filter conditions for complex alert queries", + "items": "object" + }, + { + "name": "end_time", + "type": "string", + "required": true, + "description": "End timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "fields", + "type": "array", + "required": true, + "description": "Alert event fields to analyze", + "items": "string" + }, + { + "name": "need_time_partition", + "type": "boolean", + "required": false, + "description": "Whether backend should split long time ranges into time partitions for Top N aggregation" + }, + { + "name": "query_string", + "type": "string", + "required": false, + "description": "Full-text search string" + }, + { + "name": "size", + "type": "string", + "required": false, + "description": "Maximum bucket count per field" + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "status", + "type": "array", + "required": false, + "description": "Alert status filter", + "items": "string", + "enum": [ + "ABNORMAL", + "RECOVERED", + "CLOSED" + ] + }, + { + "name": "username", + "type": "string", + "required": false, + "description": "Filter by alert owner/assignee username" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_alert_top_n/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_alert_traces", + "short": "Get traces (call chains) related to an alert", + "capabilities": [ + "relation" + ], + "prerequisites": [ + "list_alerts", + "get_alert_info" + ], + "params": [ + { + "name": "alert_id", + "type": "string", + "required": true, + "description": "Alert ID" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "limit", + "type": "string", + "required": false, + "description": "Maximum number of traces to return" + }, + { + "name": "offset", + "type": "string", + "required": false, + "description": "Pagination offset" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_alert_traces/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_strategy_detail", + "short": "Get the current strategy configuration by strategy ID in a business", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "list_alerts" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "id", + "type": "string", + "required": true, + "description": "Strategy ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_rule_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_strategy_detail/", + "query_params": [ + "bk_biz_id", + "id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "get_strategy_snapshot", + "short": "Get the strategy configuration snapshot associated with an alert", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "list_alerts" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "id", + "type": "string", + "required": true, + "description": "Numeric alert ID from list_alerts[].id or get_alert_info.id; do not use event_id" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_strategy_snapshot/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alert", + "category": "alert", + "name": "list_alerts", + "short": "List alerts in BlueKing Monitor with flexible filtering, time range, and query conditions", + "capabilities": [ + "query" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "conditions", + "type": "array", + "required": false, + "description": "Advanced filter conditions for complex queries", + "items": "object" + }, + { + "name": "end_time", + "type": "string", + "required": true, + "description": "End timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "ordering", + "type": "array", + "required": false, + "description": "Sort fields", + "items": "string" + }, + { + "name": "page", + "type": "string", + "required": false, + "description": "Page number, starts from 1" + }, + { + "name": "page_size", + "type": "string", + "required": false, + "description": "Items per page" + }, + { + "name": "query_string", + "type": "string", + "required": false, + "description": "Full-text search string" + }, + { + "name": "show_aggs", + "type": "boolean", + "required": false, + "description": "Include aggregation statistics (trends, distributions)" + }, + { + "name": "show_overview", + "type": "boolean", + "required": false, + "description": "Include overview statistics (total counts by status)" + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "status", + "type": "array", + "required": false, + "description": "Alert status filter", + "items": "string", + "enum": [ + "ABNORMAL", + "RECOVERED", + "CLOSED" + ] + }, + { + "name": "username", + "type": "string", + "required": false, + "description": "Filter by alert owner/assignee username" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/list_alerts/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "event", + "category": "event", + "name": "get_event_view_config", + "short": "Get event view config including field schema, types, and operations", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "list_events" + ], + "params": [ + { + "name": "app_name", + "type": "string", + "required": false, + "description": "APM application name" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "data_source_label", + "type": "string", + "required": true, + "description": "Data source label", + "enum": [ + "custom", + "bk_monitor" + ] + }, + { + "name": "data_type_label", + "type": "string", + "required": true, + "description": "Data type label", + "enum": [ + "event", + "log" + ] + }, + { + "name": "end_time", + "type": "string", + "required": true, + "description": "End timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "service_name", + "type": "string", + "required": false, + "description": "Service name within the APM application" + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "table", + "type": "string", + "required": true, + "description": "Event source table name" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "explore_metric_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id", + "conditional": { + "when": "app_name+service_name", + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_event_view_config/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "event", + "category": "event", + "name": "list_events", + "short": "List all event sources in BlueKing Monitor", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "data_source_label", + "type": "string", + "required": true, + "description": "Data source label" + }, + { + "name": "data_type_label", + "type": "string", + "required": true, + "description": "Data type label" + }, + { + "name": "return_dimensions", + "type": "boolean", + "required": false, + "description": "Whether to return dimension information" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "explore_metric_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/list_events/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "event", + "category": "event", + "name": "search_event_log", + "short": "Execute event search with time range and filters", + "capabilities": [ + "query" + ], + "prerequisites": [ + "list_events", + "get_event_view_config" + ], + "params": [ + { + "name": "app_name", + "type": "string", + "required": false, + "description": "APM application name" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "data_source_label", + "type": "string", + "required": true, + "description": "Data source label", + "enum": [ + "custom", + "bk_monitor" + ] + }, + { + "name": "data_type_label", + "type": "string", + "required": true, + "description": "Data type label", + "enum": [ + "event", + "log" + ] + }, + { + "name": "end_time", + "type": "string", + "required": true, + "description": "End timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "limit", + "type": "integer", + "required": false, + "description": "Maximum number of event records to return" + }, + { + "name": "offset", + "type": "integer", + "required": false, + "description": "Offset for pagination" + }, + { + "name": "query_string", + "type": "string", + "required": false, + "description": "Event search query string" + }, + { + "name": "service_name", + "type": "string", + "required": false, + "description": "Service name within the APM application" + }, + { + "name": "sort", + "type": "array", + "required": false, + "description": "Sort field list: - Type: Array of String - Rule: Each item is a field name string", + "items": "string" + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp in seconds (Unix timestamp)", + "time_unit": "s" + }, + { + "name": "table", + "type": "string", + "required": true, + "description": "Event source table name" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "explore_metric_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id", + "conditional": { + "when": "app_name+service_name", + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/search_event_log/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "calculate_by_range", + "short": "Calculate APM metrics by time range with comparison analysis", + "capabilities": [ + "analysis" + ], + "prerequisites": [ + "list_apm_applications" + ], + "params": [ + { + "name": "app_name", + "type": "string", + "required": true, + "description": "APM application name" + }, + { + "name": "baseline", + "type": "string", + "required": false, + "description": "Baseline time shift for growth rate calculation" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "end_time", + "type": "string", + "required": false, + "description": "End timestamp in seconds (Unix)", + "time_unit": "s" + }, + { + "name": "filter_dict", + "type": "object", + "required": false, + "description": "Filter conditions dict" + }, + { + "name": "group_by", + "type": "array", + "required": false, + "description": "Group-by fields for dimension aggregation", + "items": "string" + }, + { + "name": "metric_cal_type", + "type": "string", + "required": true, + "description": "Metric calculation type" + }, + { + "name": "metric_group_name", + "type": "string", + "required": true, + "description": "Metric group" + }, + { + "name": "options", + "type": "object", + "required": false, + "description": "Extra options" + }, + { + "name": "start_time", + "type": "string", + "required": false, + "description": "Start timestamp in seconds (Unix)", + "time_unit": "s" + }, + { + "name": "time_shifts", + "type": "array", + "required": false, + "description": "Time shift list for comparison", + "items": "string" + }, + { + "name": "where", + "type": "array", + "required": false, + "description": "Filter conditions list", + "items": "object" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/calculate_by_range/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "get_apm_filter_fields", + "short": "Get available filter fields for an APM app", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "list_apm_applications" + ], + "params": [ + { + "name": "app_name", + "type": "string", + "required": true, + "description": "APM application name" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_apm_filter_fields/", + "query_params": [ + "bk_biz_id", + "app_name" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "get_profile_application_service", + "short": "Profiling step 1: list profiled applications and services", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "is_get_ebpf", + "type": "boolean", + "required": false, + "description": "Whether to additionally include eBPF (DeepFlow) profile services" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_profile_application_service/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "get_profile_label", + "short": "Profiling step 3: list profile labels", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "get_profile_application_service" + ], + "params": [ + { + "name": "app_name", + "type": "string", + "required": true, + "description": "APM application name" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "data_type", + "type": "string", + "required": false, + "description": "Profile sample type, e.g" + }, + { + "name": "end_time", + "type": "integer", + "required": true, + "description": "End timestamp in seconds (Unix)", + "time_unit": "s" + }, + { + "name": "service_name", + "type": "string", + "required": true, + "description": "Service name under the application" + }, + { + "name": "start_time", + "type": "integer", + "required": true, + "description": "Start timestamp in seconds (Unix)", + "time_unit": "s" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_profile_label/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "get_profile_type", + "short": "Profiling step 2: list profile types", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "get_profile_application_service" + ], + "params": [ + { + "name": "app_name", + "type": "string", + "required": true, + "description": "APM application name" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "end_time", + "type": "integer", + "required": true, + "description": "End timestamp in seconds (Unix)", + "time_unit": "s" + }, + { + "name": "service_name", + "type": "string", + "required": true, + "description": "Service name under the application" + }, + { + "name": "start_time", + "type": "integer", + "required": true, + "description": "Start timestamp in seconds (Unix)", + "time_unit": "s" + }, + { + "name": "view_mode", + "type": "string", + "required": false, + "description": "View mode, \"default\" returns full detail object, \"sidebar\" returns a flattened list for UI sidebar" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_profile_type/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "get_span_detail", + "short": "Get detailed information of a single span", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "search_spans" + ], + "params": [ + { + "name": "app_name", + "type": "string", + "required": true, + "description": "APM application name" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "span_id", + "type": "string", + "required": true, + "description": "Span ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_span_detail/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "get_trace_detail", + "short": "Get complete trace detail including all spans in the call chain", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "search_spans" + ], + "params": [ + { + "name": "app_name", + "type": "string", + "required": true, + "description": "APM application name" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "trace_id", + "type": "string", + "required": true, + "description": "Trace ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_trace_detail/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "list_apm_applications", + "short": "List APM applications in a business", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/list_apm_applications/", + "query_params": [ + "bk_biz_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "list_apm_services", + "short": "List services in an APM application", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "list_apm_applications" + ], + "params": [ + { + "name": "app_name", + "type": "string", + "required": true, + "description": "APM application name" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "service_names", + "type": "array", + "required": false, + "description": "Optional service name list; when empty or omitted, returns all services under the app", + "items": "string" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/list_apm_services/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "query_graph_profile", + "short": "Profiling step 4: query a profile graph", + "capabilities": [ + "query", + "analysis" + ], + "prerequisites": [ + "get_profile_application_service", + "get_profile_type" + ], + "params": [ + { + "name": "agg_method", + "type": "string", + "required": false, + "description": "Aggregation method across samples" + }, + { + "name": "app_name", + "type": "string", + "required": false, + "description": "APM application name" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "data_type", + "type": "string", + "required": false, + "description": "Profile sample type" + }, + { + "name": "diff_filter_labels", + "type": "object", + "required": false, + "description": "Only used when is_compared=true" + }, + { + "name": "diff_profile_id", + "type": "string", + "required": false, + "description": "Only used when is_compared=true" + }, + { + "name": "end_time", + "type": "integer", + "required": true, + "description": "End timestamp in seconds (Unix)", + "time_unit": "s" + }, + { + "name": "filter_labels", + "type": "object", + "required": false, + "description": "Profile label filters, e.g" + }, + { + "name": "global_query", + "type": "boolean", + "required": false, + "description": "Whether to query the platform-level global storage (uploaded profiles) instead of the application storage" + }, + { + "name": "is_compared", + "type": "boolean", + "required": false, + "description": "Whether to enable diff/compare mode (compare current window with another profile / label set)" + }, + { + "name": "profile_id", + "type": "string", + "required": false, + "description": "Optional profile_id; convenience shortcut for filter_labels.profile_id, used to view the profile of a speci..." + }, + { + "name": "service_name", + "type": "string", + "required": false, + "description": "Service name under the application" + }, + { + "name": "start_time", + "type": "integer", + "required": true, + "description": "Start timestamp in seconds (Unix)", + "time_unit": "s" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/query_graph_profile/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "apm", + "category": "apm", + "name": "search_spans", + "short": "Search spans in an APM app with filters and time range", + "capabilities": [ + "query" + ], + "prerequisites": [ + "list_apm_applications", + "get_apm_filter_fields" + ], + "params": [ + { + "name": "app_name", + "type": "string", + "required": true, + "description": "APM application name" + }, + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "end_time", + "type": "string", + "required": true, + "description": "End timestamp in seconds (Unix)", + "time_unit": "s" + }, + { + "name": "filters", + "type": "array", + "required": false, + "description": "Filter conditions array", + "items": "object" + }, + { + "name": "limit", + "type": "integer", + "required": false, + "description": "Maximum number of spans to return" + }, + { + "name": "offset", + "type": "integer", + "required": false, + "description": "Offset for pagination" + }, + { + "name": "query", + "type": "string", + "required": false, + "description": "Query string for full-text search" + }, + { + "name": "sort", + "type": "array", + "required": false, + "description": "Sort conditions array", + "items": "string" + }, + { + "name": "start_time", + "type": "string", + "required": true, + "description": "Start timestamp in seconds (Unix)", + "time_unit": "s" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_apm_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_apm_application_v2", + "resource_type": "apm_application", + "resource_arg": "app_name" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/search_spans/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "dashboard", + "category": "dashboard", + "name": "get_dashboard_detail_by_uid", + "short": "Get dashboard detail by UID", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "get_dashboard_tree_list" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID 业务ID" + }, + { + "name": "dashboard_uid", + "type": "string", + "required": true, + "description": "Unique identifier of the dashboard 仪表盘的唯一标识符" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_dashboard_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_single_dashboard", + "resource_type": "grafana_dashboard", + "resource_arg": "dashboard_uid" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_dashboard_detail_by_uid/", + "query_params": [ + "bk_biz_id", + "dashboard_uid" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "dashboard", + "category": "dashboard", + "name": "get_dashboard_tree_list", + "short": "Get dashboard directory tree in a business", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID 业务ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_dashboard_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_dashboard_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_dashboard_tree_list/", + "query_params": [ + "bk_biz_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "relation", + "category": "relation", + "name": "find_relations", + "short": "Find resource relations at one time point", + "capabilities": [ + "relation" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "query_list", + "type": "array", + "required": true, + "description": "List of query items", + "items": "object" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_metrics_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "explore_metric_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/find_relations/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "relation", + "category": "relation", + "name": "find_relations_range", + "short": "Query resource relation changes over a time range with time-series results", + "capabilities": [ + "relation" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "query_list", + "type": "array", + "required": true, + "description": "List of query items", + "items": "object" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_metrics_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "explore_metric_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/find_relations_range/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alarm", + "category": "alert_handling", + "name": "get_alarm_action_config", + "short": "Get one action config by ID in a business, including is_enabled and complete execute_config", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "search_alarm_action_configs" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "id", + "type": "string", + "required": true, + "description": "Action config ID from search_alarm_action_configs" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_handling_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_rule_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_alarm_action_config/", + "query_params": [ + "bk_biz_id", + "id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alarm", + "category": "alert_handling", + "name": "get_alarm_shield", + "short": "Get one alarm shield by ID in a business", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "search_alarm_shields" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "id", + "type": "string", + "required": true, + "description": "Shield ID from search_alarm_shields" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_handling_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_downtime_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_alarm_shield/", + "query_params": [ + "bk_biz_id", + "id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alarm", + "category": "alert_handling", + "name": "get_alarm_strategy", + "short": "Get one alarm strategy by an exact strategy_id or strategy_name condition", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "search_alarm_strategies" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "conditions", + "type": "array", + "required": true, + "description": "", + "items": "object" + }, + { + "name": "convert_dashboard", + "type": "boolean", + "required": false, + "description": "Convert dashboard configuration" + }, + { + "name": "with_user_group", + "type": "boolean", + "required": false, + "description": "Include notice group information" + }, + { + "name": "with_user_group_detail", + "type": "boolean", + "required": false, + "description": "Include full notice group details" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_handling_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_rule_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_alarm_strategy/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alarm", + "category": "alert_handling", + "name": "search_alarm_action_configs", + "short": "List action configs in one business, including is_enabled", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "order", + "type": "string", + "required": false, + "description": "Sort field", + "enum": [ + "-id", + "id", + "create_time", + "-create_time", + "update_time", + "-update_time" + ] + }, + { + "name": "page", + "type": "integer", + "required": false, + "description": "Page number" + }, + { + "name": "page_size", + "type": "integer", + "required": false, + "description": "Items per page" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_handling_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_rule_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/search_alarm_action_configs/", + "query_params": [ + "bk_biz_id", + "page", + "page_size", + "order" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alarm", + "category": "alert_handling", + "name": "search_alarm_assign_groups", + "short": "Search alert assign rule groups in one business, including rules, priority, and source", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "group_ids", + "type": "array", + "required": false, + "description": "Assign group IDs to filter", + "items": "string" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_handling_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_rule_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/search_alarm_assign_groups/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alarm", + "category": "alert_handling", + "name": "search_alarm_notice_groups", + "short": "Search notice groups in one business by ID or fuzzy name", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "ids", + "type": "array", + "required": false, + "description": "Notice group IDs", + "items": "string" + }, + { + "name": "name", + "type": "string", + "required": false, + "description": "Fuzzy notice group name" + }, + { + "name": "with_detail", + "type": "boolean", + "required": false, + "description": "Return complete group details" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_handling_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_notify_team_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/search_alarm_notice_groups/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alarm", + "category": "alert_handling", + "name": "search_alarm_shields", + "short": "Search alarm shields in one business", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "categories", + "type": "array", + "required": false, + "description": "Shield categories", + "items": "string", + "enum": [ + "scope", + "strategy", + "event", + "alert", + "dimension" + ] + }, + { + "name": "conditions", + "type": "array", + "required": false, + "description": "Shield model filters", + "items": "object" + }, + { + "name": "is_active", + "type": "boolean", + "required": false, + "description": "Whether to return active shields" + }, + { + "name": "order", + "type": "string", + "required": false, + "description": "Sort field", + "enum": [ + "-id", + "id", + "begin_time", + "-begin_time", + "failure_time", + "-failure_time" + ] + }, + { + "name": "page", + "type": "integer", + "required": false, + "description": "Page number" + }, + { + "name": "page_size", + "type": "integer", + "required": false, + "description": "Items per page" + }, + { + "name": "source", + "type": "string", + "required": false, + "description": "Shield source" + }, + { + "name": "time_range", + "type": "string", + "required": false, + "description": "Time range in \"start -- end\" form" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_handling_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_downtime_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/search_alarm_shields/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "alarm", + "category": "alert_handling", + "name": "search_alarm_strategies", + "short": "Search alarm strategies", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "conditions", + "type": "array", + "required": false, + "description": "Strategy filter conditions", + "items": "object" + }, + { + "name": "convert_dashboard", + "type": "boolean", + "required": false, + "description": "Convert dashboard configuration in strategy results" + }, + { + "name": "page", + "type": "integer", + "required": false, + "description": "Page number" + }, + { + "name": "page_size", + "type": "integer", + "required": false, + "description": "Items per page" + }, + { + "name": "scenario", + "type": "string", + "required": false, + "description": "Monitoring scenario, such as os, host_process, kubernetes, or uptimecheck" + }, + { + "name": "with_user_group", + "type": "boolean", + "required": false, + "description": "Include notice group summaries" + }, + { + "name": "with_user_group_detail", + "type": "boolean", + "required": false, + "description": "Include notice group details" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_handling_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_rule_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/search_alarm_strategies/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "collect", + "category": "log_collection", + "name": "get_log_collector", + "short": "Get one collector by collector_config_id", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "list_log_collectors" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "collector_config_id", + "type": "integer", + "required": true, + "description": "Collector configuration ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_collection_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_log_collector/", + "query_params": [ + "bk_biz_id", + "collector_config_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "collect", + "category": "log_collection", + "name": "get_log_collector_status", + "short": "Query one collector's deployment task and subscription status", + "capabilities": [ + "detail" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "collector_config_id", + "type": "integer", + "required": true, + "description": "Collector configuration ID" + }, + { + "name": "detail_limit", + "type": "integer", + "required": false, + "description": "Max instance-detail rows across task and subscription phases" + }, + { + "name": "task_ids", + "type": "array", + "required": false, + "description": "Task IDs returned by fast_create_log_collector / fast_update_log_collector" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_collection_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_collection_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/get_log_collector_status/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "collect", + "category": "log_collection", + "name": "get_log_index_set", + "short": "Get an independent log index set by index_set_id", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "list_log_collectors" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "index_set_id", + "type": "integer", + "required": true, + "description": "Independent index-set ID from list_log_collectors.index_set_id" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_collection_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_log_index_set/", + "query_params": [ + "bk_biz_id", + "index_set_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "collect", + "category": "log_collection", + "name": "list_log_collectors", + "short": "List log access entries in a business with stable pagination", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "collector_scenario_id", + "type": "string", + "required": false, + "description": "Log type / collection scenario", + "enum": [ + "row", + "section", + "wineventlog", + "custom", + "redis_slowlog", + "syslog", + "kafka", + "client" + ] + }, + { + "name": "conditions", + "type": "string", + "required": false, + "description": "JSON array of advanced filters, such as [{\"key\":\"name\",\"value\":[\"nginx\"]}]" + }, + { + "name": "keyword", + "type": "string", + "required": false, + "description": "Fuzzy search against collector display name, English name/result table, or business ID" + }, + { + "name": "log_access_type", + "type": "array", + "required": false, + "description": "Filter by unified access type", + "items": "string", + "enum": [ + "linux", + "winevent", + "container_file", + "container_stdout", + "bkdata", + "es", + "custom_report" + ] + }, + { + "name": "ordering", + "type": "string", + "required": false, + "description": "Sort by a supported list field; prefix with - for descending order", + "enum": [ + "name", + "-name", + "retention", + "-retention", + "updated_at", + "-updated_at", + "created_at", + "-created_at" + ] + }, + { + "name": "page", + "type": "integer", + "required": false, + "description": "Page number, starting from 1" + }, + { + "name": "page_size", + "type": "integer", + "required": false, + "description": "Number of collectors per page" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_collection_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/list_log_collectors/", + "query_params": [ + "bk_biz_id", + "page", + "page_size", + "keyword", + "collector_scenario_id", + "log_access_type", + "conditions", + "ordering" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "collect", + "category": "log_collection", + "name": "list_log_index_set_groups", + "short": "List index groups available to a business", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_collection_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/list_log_index_set_groups/", + "query_params": [ + "bk_biz_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "collect", + "category": "log_collection", + "name": "list_result_tables", + "short": "List result tables / indexes available to a business", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "result_table_id", + "type": "string", + "required": false, + "description": "Optional index name to fuzzy match" + }, + { + "name": "scenario_id", + "type": "string", + "required": true, + "description": "Access scenario", + "enum": [ + "bkdata", + "es" + ] + }, + { + "name": "storage_cluster_id", + "type": "integer", + "required": false, + "description": "Storage cluster ID, required when scenario_id=es" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_collection_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/list_result_tables/", + "query_params": [ + "bk_biz_id", + "scenario_id", + "storage_cluster_id", + "result_table_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "collect", + "category": "log_collection", + "name": "list_third_party_es_clusters", + "short": "List third-party Elasticsearch storage clusters available to a business", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_collection_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/list_third_party_es_clusters/", + "query_params": [ + "bk_biz_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "collect", + "category": "log_collection", + "name": "preview_log_etl", + "short": "Preview field extraction from one sample log", + "capabilities": [ + "analysis" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + }, + { + "name": "data", + "type": "string", + "required": true, + "description": "One sample log line for preview" + }, + { + "name": "etl_config", + "type": "string", + "required": true, + "description": "Cleaning type", + "enum": [ + "bk_log_text", + "bk_log_json", + "bk_log_regexp", + "bk_log_delimiter" + ] + }, + { + "name": "etl_params", + "type": "object", + "required": false, + "description": "Complete cleaning parameters (full replacement)" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_collection_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_collection_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/preview_log_etl/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "extract", + "category": "log_extract", + "name": "get_log_extract_task", + "short": "Get a log-extraction task state", + "capabilities": [ + "detail" + ], + "prerequisites": [ + "create_log_extract_task" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "task_id", + "type": "integer", + "required": true, + "description": "Log extraction task ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_extract_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/get_log_extract_task/", + "query_params": [ + "bk_biz_id", + "task_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "extract", + "category": "log_extract", + "name": "list_log_extract_allowed_paths", + "short": "List paths allowed for log extraction", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "search_log_extract_hosts" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "ip_list", + "type": "array", + "required": false, + "description": "Optional; an empty array is treated as omitted", + "items": "object" + }, + { + "name": "target_node_type", + "type": "string", + "required": false, + "description": "INSTANCE accepts ip_list or host target_nodes; TOPO and SERVICE_TEMPLATE require target_nodes", + "enum": [ + "INSTANCE", + "TOPO", + "SERVICE_TEMPLATE" + ] + }, + { + "name": "target_nodes", + "type": "array", + "required": false, + "description": "Optional; an empty array is treated as omitted.", + "items": "object" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_extract_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/list_log_extract_allowed_paths/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "extract", + "category": "log_extract", + "name": "list_log_extract_topology", + "short": "List topology nodes that the current user may access through log-extraction policies", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_extract_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/list_log_extract_topology/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "extract", + "category": "log_extract", + "name": "search_log_extract_hosts", + "short": "Query hosts accessible under log-extraction policies", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "list_log_extract_topology" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID" + }, + { + "name": "node_list", + "type": "array", + "required": false, + "description": "Optional authorized topology nodes returned by list_log_extract_topology", + "items": "object" + }, + { + "name": "page_size", + "type": "integer", + "required": false, + "description": "" + }, + { + "name": "search_content", + "type": "string", + "required": false, + "description": "Fuzzy search across internal IPv4, internal IPv6, host name, operating-system type, and operating-system name" + }, + { + "name": "start", + "type": "integer", + "required": false, + "description": "" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_log_extract_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_log_search", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "POST", + "path": "/mcp/search_log_extract_hosts/", + "query_params": [], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "space", + "category": "metadata", + "name": "list_bcs_clusters", + "short": "List BCS (BlueKing Container Service) clusters in a business", + "capabilities": [ + "discovery" + ], + "prerequisites": [ + "search_spaces" + ], + "params": [ + { + "name": "bk_biz_id", + "type": "string", + "required": true, + "description": "Business ID in BlueKing Monitor" + } + ], + "permission": { + "mode": "mcp_then_native", + "mcp": { + "system_id": "bk_monitorv3", + "action_id": "using_metadata_mcp", + "resource_type": "space" + }, + "native": { + "system_id": "bk_monitorv3", + "action_id": "view_business_v2", + "resource_type": "space", + "resource_arg": "bk_biz_id" + } + }, + "endpoint": { + "method": "GET", + "path": "/mcp/search_bcs_clusters/", + "query_params": [ + "bk_biz_id" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + }, + { + "module": "space", + "category": "metadata", + "name": "search_spaces", + "short": "Find business spaces by name", + "capabilities": [ + "discovery" + ], + "prerequisites": [], + "params": [ + { + "name": "page", + "type": "string", + "required": false, + "description": "Page number, starts from 1" + }, + { + "name": "page_size", + "type": "string", + "required": false, + "description": "Items per page" + }, + { + "name": "space_name", + "type": "string", + "required": true, + "description": "Search keyword for fuzzy matching space/business names" + } + ], + "permission": { + "mode": "exempt" + }, + "endpoint": { + "method": "GET", + "path": "/mcp/search_spaces/", + "query_params": [ + "space_name", + "page", + "page_size" + ], + "auth": { + "userVerifiedRequired": true, + "appVerifiedRequired": false, + "resourcePermissionRequired": true + } + } + } + ], + "actions": { + "bk_monitorv3:using_metrics_mcp": "使用指标MCP", + "bk_monitorv3:using_log_mcp": "使用日志MCP", + "bk_monitorv3:using_alarm_mcp": "使用告警查询MCP", + "bk_monitorv3:using_apm_mcp": "使用APM MCP", + "bk_monitorv3:using_dashboard_mcp": "使用仪表盘MCP", + "bk_monitorv3:using_alarm_handling_mcp": "使用告警处置MCP", + "bk_monitorv3:using_log_collection_mcp": "使用日志采集MCP", + "bk_monitorv3:using_log_extract_mcp": "使用日志提取MCP", + "bk_monitorv3:using_metadata_mcp": "使用元数据MCP", + "bk_monitorv3:view_business_v2": "业务访问", + "bk_monitorv3:explore_metric_v2": "指标检索", + "bk_monitorv3:view_event_v2": "事件中心查看", + "bk_monitorv3:view_rule_v2": "策略查看", + "bk_monitorv3:view_downtime_v2": "屏蔽查看", + "bk_monitorv3:view_notify_team_v2": "告警组查看", + "bk_monitorv3:view_dashboard_v2": "仪表盘查看", + "bk_monitorv3:view_single_dashboard": "仪表盘实例查看", + "bk_monitorv3:view_apm_application_v2": "APM应用查看", + "bk_monitorv3:view_collection_v2": "采集查看", + "bk_log_search:view_business_v2": "业务访问", + "bk_log_search:search_log_v2": "日志检索" + }, + "resource_types": { + "space": "业务", + "indices": "索引集", + "apm_application": "APM 应用", + "grafana_dashboard": "仪表盘" + } +} diff --git a/cmd/system/monitor/context_anchor.go b/cmd/system/monitor/context_anchor.go new file mode 100644 index 0000000..109e97f --- /dev/null +++ b/cmd/system/monitor/context_anchor.go @@ -0,0 +1,203 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "strconv" + "strings" + + json "github.com/goccy/go-json" + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" +) + +var anchorFields = []string{"dtEventTimeStamp", "serverIp", "gseIndex", "iterationIndex", "path"} + +// applyContextAnchor copies exact positioning fields from a saved search result. It never invents values. +func applyContextAnchor(cmd *cobra.Command, args map[string]any) error { + source, _ := cmd.Flags().GetString("from") + if source == "" { + return nil + } + var raw []byte + var err error + if source == "-" { + raw, err = readAllLimited(cmd.InOrStdin()) + } else { + raw, err = readFileLimited(strings.TrimPrefix(source, "@")) + } + if err != nil { + return monitorapi.InputError("invalid_from", "无法读取 --from,或内容超过 64 KiB。") + } + record, sourceIndex, provenance, err := firstRecord(raw) + if err != nil { + return err + } + if err := validateAnchorScope(cmd, args, provenance); err != nil { + return err + } + index, ok, err := sourceIndexID(record["__index_set_id__"], sourceIndex, + monitorapi.Map(provenance["resolved_parameters"])["index_set_id"]) + if err != nil { + return err + } + if args["index_set_id"] == nil { + if !ok || index <= 0 { + return monitorapi.InputError("needs_index_set", "记录里没有索引集 ID;请传 --index_set_id。") + } + args["index_set_id"] = index + } else if ok && strconv.FormatInt(index, 10) != strings.TrimSpace(jsonText(args["index_set_id"])) { + return monitorapi.InputError("anchor_index_mismatch", "定位记录属于另一个索引集;不改写定位来源。") + } + for _, name := range anchorFields { + if args[name] != nil { + continue + } + value, present, err := anchorValue(record[name]) + if err != nil { + return monitorapi.InputError("invalid_anchor", name+" 必须是原始字符串或精确整数。") + } + if present { + args[name] = value + } + } + if args["zero"] == nil { + args["zero"] = true + } + if args["begin"] == nil { + args["begin"] = "0" + } + if args["size"] == nil { + args["size"] = "5" + } + return nil +} + +func firstRecord(raw []byte) (map[string]any, any, map[string]any, error) { + var value any + if err := decodeJSON(raw, &value); err != nil { + return nil, nil, nil, monitorapi.InputError("invalid_from", "--from 必须是单个完整 JSON。") + } + record := monitorapi.Map(value) + if record["ok"] == false || record["result"] == false { + return nil, nil, nil, monitorapi.InputError("invalid_from", "失败响应不能作为日志定位来源。") + } + if record["__demo_mock__"] == true { + return nil, nil, nil, monitorapi.InputError("mock_record", "模拟记录不能发往真实服务。") + } + provenance := monitorapi.Map(record["profile"]) + if record["profile"] != nil && provenance == nil { + return nil, nil, nil, monitorapi.InputError("invalid_from", "定位来源的 Profile 信息不合法。") + } + var sourceIndex any + if data := monitorapi.Map(record["data"]); data != nil { + sourceIndex = data["index_set_id"] + if rows := monitorapi.List(data["list"]); len(rows) > 0 { + record = monitorapi.Map(rows[0]) + } + } + if record == nil || record["dtEventTimeStamp"] == nil { + return nil, nil, nil, monitorapi.InputError("invalid_from", "--from 需要 search_logs 的输出,或一条日志记录。") + } + if record["__demo_mock__"] == true { + return nil, nil, nil, monitorapi.InputError("mock_record", "模拟记录不能发往真实服务。") + } + return record, sourceIndex, provenance, nil +} + +func validateAnchorScope(cmd *cobra.Command, args, provenance map[string]any) error { + if provenance == nil { + return nil + } // Raw records have no verifiable local provenance; server still authorizes. + sourceContext, _ := provenance["context"].(string) + if sourceContext == "" || provenance["bk_biz_id"] == nil { + return monitorapi.InputError("invalid_from", "定位来源缺少 Profile 的 context 或业务信息。") + } + current, _ := cmd.Context().Value(runtimeContextKey{}).(string) + if sourceContext != current { + return monitorapi.InputError("anchor_context_mismatch", "定位记录来自另一个 context;不跨环境复用日志定位。") + } + if jsonText(provenance["bk_biz_id"]) != jsonText(args["bk_biz_id"]) { + return monitorapi.InputError("anchor_business_mismatch", "定位记录来自另一个业务;不改写定位来源。") + } + return nil +} + +func sourceIndexID(values ...any) (int64, bool, error) { + var index int64 + found := false + for _, value := range values { + if value == nil { + continue + } + candidate, ok := exactInteger(value) + if !ok || candidate <= 0 { + return 0, false, monitorapi.InputError("invalid_anchor_index", "定位来源中的索引集 ID 必须是正整数。") + } + if found && index != candidate { + return 0, false, monitorapi.InputError("anchor_index_mismatch", "定位记录与来源元数据的索引集 ID 不一致。") + } + index, found = candidate, true + } + return index, found, nil +} + +func exactInteger(value any) (int64, bool) { + switch typed := value.(type) { + case json.Number: + parsed, err := strconv.ParseInt(typed.String(), 10, 64) + return parsed, err == nil + case string: + parsed, err := strconv.ParseInt(typed, 10, 64) + return parsed, err == nil + case int64: + return typed, true + case int: + return int64(typed), true + } + return 0, false +} + +func anchorValue(value any) (string, bool, error) { + switch typed := value.(type) { + case nil: + return "", false, nil + case string: + return typed, typed != "", nil + case json.Number: + if _, err := strconv.ParseInt(typed.String(), 10, 64); err != nil { + return "", false, err + } + return typed.String(), true, nil + } + return "", false, strconv.ErrSyntax +} + +func jsonText(value any) string { + switch typed := value.(type) { + case int64: + return strconv.FormatInt(typed, 10) + case string: + return typed + default: + raw, _ := json.Marshal(typed) + return string(raw) + } +} diff --git a/cmd/system/monitor/convenience.go b/cmd/system/monitor/convenience.go new file mode 100644 index 0000000..6b194e1 --- /dev/null +++ b/cmd/system/monitor/convenience.go @@ -0,0 +1,153 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" +) + +const ( + sceneFlag = "scene" + whereFlag = "where" +) + +var examples = map[string][]string{ + "search_spaces": {"bk-cli monitor space search_spaces --space_name 蓝鲸"}, + "list_log_scenes": {"bk-cli monitor log list_log_scenes --bk_biz_id "}, + "get_scene_log_fields": { + "bk-cli monitor log get_scene_log_fields --bk_biz_id --scene host", + "bk-cli monitor log get_scene_log_fields --bk_biz_id --scene k8s --where cluster_id=", + }, + "search_logs": { + "bk-cli monitor log search_logs --bk_biz_id --scene host --query_string ERROR --since 15m", + "bk-cli monitor log search_logs --bk_biz_id --index_set_id " + + " --query_string ERROR --since 1h", + }, + "search_index_set_context": { + "bk-cli monitor log search_logs --bk_biz_id --index_set_id --limit 1 --since 1h > hit.json", + "bk-cli monitor log search_index_set_context --bk_biz_id --from hit.json --size 5", + }, + "list_alerts": {"bk-cli monitor alert list_alerts --bk_biz_id --status ABNORMAL --since 1h"}, + "get_alert_info": {"bk-cli monitor alert get_alert_info --bk_biz_id --id "}, + "execute_range_query": { + "bk-cli monitor metric execute_range_query --bk_biz_id " + + " --promql 'sum(custom::)' --since 1h", + }, +} + +func curatedExample(name string) string { + lines := examples[name] + for i := range lines { + lines[i] = " " + lines[i] + } + return strings.Join(lines, "\n") +} + +func addConvenienceFlags(cmd *cobra.Command, item tool) { + if item.Name == "search_logs" { + cmd.Flags().StringArray( + "filter", + nil, + "Log row FIELD=VALUE phrase filter; repeatable, AND with query_string; not table routing", + ) + } + switch item.Name { + case "search_logs", "get_scene_log_fields": + cmd.Flags().String( + sceneFlag, + "", + "Scene from list_log_scenes, such as host or k8s; builds table_id_conditions", + ) + cmd.Flags().StringArray( + whereFlag, + nil, + "Table route KEY=VALUE, such as cluster_id; use --filter or query_string for row fields like namespace", + ) + case "search_index_set_context": + cmd.Flags().String( + "from", + "", + "Saved search_logs output or one log record (@file is optional, - for stdin)", + ) + } +} + +// applyConvenienceFlags only builds documented API arguments; it does not infer resources or permissions. +func applyConvenienceFlags(cmd *cobra.Command, item tool, args map[string]any) error { + switch item.Name { + case "search_logs", "get_scene_log_fields": + p := currentProfile(cmd) + if p == nil || p.Profile.Log == nil || p.Profile.Log.Scene == "" { + if err := applyScene(cmd, item, args); err != nil { + return err + } + } + if item.Name == "search_logs" { + return applyLogFilters(cmd, args) + } + return nil + case "search_index_set_context": + return applyContextAnchor(cmd, args) + } + return nil +} + +func applyScene(cmd *cobra.Command, item tool, args map[string]any) error { + scene, _ := cmd.Flags().GetString(sceneFlag) + where, _ := cmd.Flags().GetStringArray(whereFlag) + if scene == "" { + if len(where) > 0 { + return monitorapi.InputError("where_needs_scene", "--where 需要和 --scene 一起使用。") + } + if item.Name == "search_logs" && args["index_set_id"] == nil && args["table_id_conditions"] == nil { + biz := jsonText(args["bk_biz_id"]) + return monitorapi.InputError("needs_resource", "需要选择日志场景或索引集。这不是无权。", + "bk-cli monitor log list_log_scenes --bk_biz_id "+biz, + "bk-cli monitor log list_index_sets --bk_biz_id "+biz) + } + if item.Name == "search_logs" && args["index_set_id"] != nil && args["target_type"] == nil { + args["target_type"] = "index_set" + } + return nil + } + if args["index_set_id"] != nil || args["table_id_conditions"] != nil { + return monitorapi.InputError( + "conflicting_target", + "--scene 不能与 --index_set_id 或 --table_id_conditions 同时使用。", + ) + } + conditions := []any{map[string]any{"field_name": "scene", "value": []any{scene}, "op": "eq"}} + for _, raw := range where { + key, value, ok := strings.Cut(raw, "=") + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + if !ok || key == "" || value == "" { + return monitorapi.InputError("invalid_where", "--where 使用 KEY=VALUE。") + } + conditions = append(conditions, map[string]any{"field_name": key, "value": []any{value}, "op": "eq"}) + } + args["table_id_conditions"] = []any{conditions} + if item.Name == "search_logs" { + args["target_type"] = "scene" + } + return nil +} diff --git a/cmd/system/monitor/hints.go b/cmd/system/monitor/hints.go new file mode 100644 index 0000000..5b98aa8 --- /dev/null +++ b/cmd/system/monitor/hints.go @@ -0,0 +1,102 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "errors" + "strconv" + + json "github.com/goccy/go-json" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" +) + +// Carry only permission resource identifiers, never query text, time windows or log bodies. +func permissionHint(contextName, action, name, biz string, values map[string]any) string { + bizArg := biz + if _, err := strconv.ParseInt(biz, 10, 64); err != nil { + bizArg = shellQuote(biz) + } + command := "bk-cli monitor permission " + action + " --bk_biz_id " + bizArg + " --tool " + name + resource := map[string]any{} + for _, key := range []string{"index_set_id", "target_type", "app_name", "service_name", "dashboard_uid"} { + if value, exists := values[key]; exists { + switch value.(type) { + case string, int, int64, json.Number: + resource[key] = value + } + } + } + if len(resource) > 0 { + body, err := json.Marshal(resource) + if err == nil { + command += " --body " + shellQuote(string(body)) + } + } + if contextName != "" { + command += " --context " + contextName + } + return command +} + +func preservePermissionTarget(err error, contextName, name, biz string, values map[string]any) error { + var decision *monitorapi.Decision + if errors.As(err, &decision) && + (decision.Code == "permission_missing" || decision.Code == "permission_denied") { + decision.Next = []string{permissionHint(contextName, "apply", name, biz, values)} + } + return err +} + +func profileSuffix(p *selectedProfile) string { + if p == nil { + return "" + } + suffix := " --profile " + p.Name + " --profile_file " + shellQuote(p.File) + if p.Version == 2 { + if p.Component != "" { + suffix += " --component " + p.Component + } + if p.Binding != "" { + suffix += " --binding " + p.Binding + } + } + return suffix +} + +func profileDiagnosticNext(row map[string]any, name string, p *selectedProfile) []string { + code, _ := row["decision"].(string) + suffix := profileSuffix(p) + switch code { + case "permission_missing", "permission_denied": + return []string{permissionHint(p.Profile.Context, "apply", name, p.Profile.BizID, nil) + suffix} + case "auth_failed", "auth_required", "identity_unverified": + return []string{"bk-cli auth status --context " + p.Profile.Context} + case "resource_not_found", "resource_not_visible", "needs_resource": + commands := nextCommands(name, p.Profile.BizID) + if len(commands) > 0 { + for i := range commands { + commands[i] += suffix + } + return commands + } + } + // Metadata/service failures are not fixed by applying for more permissions. + return []string{"bk-cli monitor profile show" + suffix} +} diff --git a/cmd/system/monitor/input.go b/cmd/system/monitor/input.go new file mode 100644 index 0000000..8d272ef --- /dev/null +++ b/cmd/system/monitor/input.go @@ -0,0 +1,50 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "errors" + "io" + "os" +) + +const maxInputBytes = 64 * 1024 + +func readAllLimited(reader io.Reader) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(reader, maxInputBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxInputBytes || len(data) == 0 { + return nil, errors.New("input is empty or too large") + } + return data, nil +} + +func readFileLimited(path string) ([]byte, error) { + if path == "" { + return nil, errors.New("path is required") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + return readAllLimited(file) +} diff --git a/cmd/system/monitor/json.go b/cmd/system/monitor/json.go new file mode 100644 index 0000000..163258b --- /dev/null +++ b/cmd/system/monitor/json.go @@ -0,0 +1,45 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "bytes" + "errors" + + json "github.com/goccy/go-json" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" +) + +// decodeJSON consumes exactly one JSON value and preserves the original number literals. +func decodeJSON(raw []byte, target any) error { + if !json.Valid(raw) { + return errors.New("invalid JSON") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + return decoder.Decode(target) +} + +func jsonObject(raw []byte, target *map[string]any) error { + if err := decodeJSON(raw, target); err != nil || *target == nil { + return monitorapi.InputError("invalid_body", "--body 必须是 JSON 对象。") + } + return nil +} diff --git a/cmd/system/monitor/log_filters.go b/cmd/system/monitor/log_filters.go new file mode 100644 index 0000000..63dbf5d --- /dev/null +++ b/cmd/system/monitor/log_filters.go @@ -0,0 +1,101 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "maps" + "regexp" + "slices" + "sort" + "strings" + "unicode" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" +) + +var logFilterFieldPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]*$`) + +func validateLogFilters(filters map[string]string) error { + for key, value := range filters { + if !logFilterFieldPattern.MatchString(key) || strings.TrimSpace(value) == "" || + strings.ContainsFunc(value, unicode.IsControl) || strings.ContainsAny(value, "\u2028\u2029") { + return monitorapi.InputError("invalid_log_filter", "日志过滤需要合法字段名和非空单行值;复杂表达式用 --query_string。") + } + } + return nil +} + +// Row filters are distinct from table routing. Compile literal field phrases into +// the existing query_string API; never invent a new backend parameter or switch sources. +func applyLogFilters(cmd *cobra.Command, args map[string]any) error { + filters := map[string]string{} + selection := currentProfile(cmd) + if selection != nil && selection.Profile.Log != nil { + maps.Copy(filters, selection.Profile.Log.Filters) + } + flags, _ := cmd.Flags().GetStringArray("filter") + for _, raw := range flags { + key, value, ok := strings.Cut(raw, "=") + if !ok { + return monitorapi.InputError("invalid_log_filter", "--filter 使用 FIELD=VALUE,可重复。") + } + filters[strings.TrimSpace(key)] = value + } + if len(filters) == 0 { + return nil + } + if err := validateLogFilters(filters); err != nil { + return err + } + keys := make([]string, 0, len(filters)) + for key := range filters { + keys = append(keys, key) + } + sort.Strings(keys) + terms := make([]string, 0, len(keys)) + for _, key := range keys { + // Escape Lucene phrase delimiters, not HTML or JSON Unicode sequences. + value := strings.ReplaceAll(filters[key], `\`, `\\`) + value = strings.ReplaceAll(value, `"`, `\"`) + terms = append(terms, key+`:"`+value+`"`) + } + expression := strings.Join(terms, " AND ") + query := "" + if value, exists := args["query_string"]; exists { + var ok bool + query, ok = value.(string) + if !ok { + return monitorapi.InputError("invalid_query_string", "query_string 必须是字符串。") + } + } + if strings.TrimSpace(query) != "" && query != "*" { + expression = "(" + query + ") AND (" + expression + ")" + } + args["query_string"] = expression + if selection != nil { + selection.RowFilters = filters + if selection.Profile.Log != nil && len(selection.Profile.Log.Filters) > 0 && + !slices.Contains(selection.Applied, "log.filters") { + selection.Applied = append(selection.Applied, "log.filters") + } + } + return nil +} diff --git a/cmd/system/monitor/log_filters_test.go b/cmd/system/monitor/log_filters_test.go new file mode 100644 index 0000000..2a498aa --- /dev/null +++ b/cmd/system/monitor/log_filters_test.go @@ -0,0 +1,140 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + systemtest "github.com/TencentBlueKing/bk-cli/cmd/system/testutil" + "github.com/TencentBlueKing/bk-cli/internal/output" +) + +var _ = Describe("log row filters", func() { + var dir string + BeforeEach(func() { + dir = GinkgoT().TempDir() + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", dir) + GinkgoT().Setenv("BK_CLI_MONITOR_PROFILE_FILE", "") + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + }) + + It("keeps table routing separate from row filters and parenthesizes an OR query", func() { + cmd := toolCommand("search_logs", true) + setFlags( + cmd, + "bk_biz_id", + "100100", + "scene", + "k8s", + "where", + "cluster_id=test-cluster", + "filter", + "__ext.io_kubernetes_pod_namespace=ns-a", + "query_string", + "ERROR OR WARN", + "since", + "15m", + ) + args := dryRunArgs(cmd) + Expect(args["table_id_conditions"].([]any)[0]).To(HaveLen(2)) + Expect(args["query_string"]).To(Equal(`(ERROR OR WARN) AND (__ext.io_kubernetes_pod_namespace:"ns-a")`)) + Expect(args).NotTo(HaveKey("filters")) + Expect(args).NotTo(HaveKey("filter")) + }) + + It("escapes literal quotes and backslashes without changing index-set mode", func() { + cmd := toolCommand("search_logs", true) + setFlags(cmd, "bk_biz_id", "100100", "index_set_id", "101003", "filter", `field=a"b\c`, "since", "15m") + args := dryRunArgs(cmd) + Expect(args["target_type"]).To(Equal("index_set")) + Expect(args["query_string"]).To(Equal(`field:"a\"b\\c"`)) + }) + + It("keeps Unicode and HTML punctuation literal rather than JSON-escaping the query language", func() { + cmd := toolCommand("search_logs", true) + setFlags( + cmd, + "bk_biz_id", + "100100", + "index_set_id", + "101003", + "filter", + "component=测试", + "since", + "15m", + ) + Expect(dryRunArgs(cmd)["query_string"]).To(Equal(`component:"测试"`)) + }) + + It("overrides profile filters per key while retaining the other defaults", func() { + file := filepath.Join(dir, "profiles.yaml") + Expect(os.WriteFile(file, []byte(`version: 1 +profiles: + app: + context: default + bk_biz_id: "100100" + log: + scene: k8s + where: {cluster_id: test-cluster} + filters: {namespace: ns-a, component: api} +`), 0o600)).To(Succeed()) + cmd := toolCommand("search_logs", true) + setFlags( + cmd, + "profile", + "app", + "profile_file", + file, + "filter", + "namespace=ns-b", + "query_string", + "*", + "since", + "15m", + ) + env, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + args := previewArgs(env) + Expect(args["query_string"]).To(Equal(`component:"api" AND namespace:"ns-b"`)) + Expect(args["table_id_conditions"].([]any)[0]).To(HaveLen(2)) + profile := env["profile"].(map[string]any) + Expect(profile["applied_fields"]).To(ContainElement("log.filters")) + Expect( + profile["resolved_parameters"].(map[string]any)["log_filters"], + ).To( + HaveKeyWithValue("namespace", "ns-b"), + ) + }) + + DescribeTable("rejects invalid filters locally", func(value string) { + cmd := toolCommand("search_logs", true) + setFlags(cmd, "bk_biz_id", "100100", "index_set_id", "101003", "filter", value, "since", "15m") + _, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("invalid_log_filter")) + }, + Entry("missing value", "namespace="), + Entry("missing equals", "namespace"), + Entry("expression in field", "ns) OR *=all"), + Entry("newline", "namespace=ns-a\nOR *"), + ) +}) diff --git a/cmd/system/monitor/monitor_suite_test.go b/cmd/system/monitor/monitor_suite_test.go new file mode 100644 index 0000000..da7839b --- /dev/null +++ b/cmd/system/monitor/monitor_suite_test.go @@ -0,0 +1,31 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMonitor(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Monitor Suite") +} diff --git a/cmd/system/monitor/monitor_test.go b/cmd/system/monitor/monitor_test.go new file mode 100644 index 0000000..9a56b98 --- /dev/null +++ b/cmd/system/monitor/monitor_test.go @@ -0,0 +1,402 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "time" + + json "github.com/goccy/go-json" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + systemtest "github.com/TencentBlueKing/bk-cli/cmd/system/testutil" + "github.com/TencentBlueKing/bk-cli/internal/output" +) + +var _ = Describe("monitor command tree", func() { + var tmpDir string + + BeforeEach(func() { + var err error + tmpDir, err = os.MkdirTemp("", "bk-cli-monitor-*") + Expect(err).NotTo(HaveOccurred()) + Expect(os.Setenv("BK_CLI_CONFIG_DIR", tmpDir)).To(Succeed()) + now = func() time.Time { return time.Unix(1800000000, 0) } + }) + + AfterEach(func() { + now = time.Now + Expect(os.Unsetenv("BK_CLI_CONFIG_DIR")).To(Succeed()) + Expect(os.RemoveAll(tmpDir)).To(Succeed()) + }) + + It("exposes every reviewed read-only tool in exactly one module", func() { + Expect(catalogErr).NotTo(HaveOccurred()) + Expect(catalogData.Tools).To(HaveLen(65)) + seen := map[string]bool{} + for _, item := range catalogData.Tools { + Expect(seen).NotTo(HaveKey(item.Name)) + seen[item.Name] = true + Expect(commandName(item.Name)).To(Equal("bk-cli monitor " + item.Module + " " + item.Name)) + } + Expect(seen).NotTo(HaveKey("create_dashboard")) + Expect(seen).NotTo(HaveKey("get_log_extract_download_url")) + }) + + It("documents permission requirements for every module and tool", func() { + for _, item := range catalogData.Tools { + help := longHelp(item, false) + if item.Permission.Mode == "exempt" { + Expect(help).To(ContainSubstring("no business permission is required")) + continue + } + Expect(item.Permission.MCP).NotTo(BeNil(), item.Name) + Expect(item.Permission.Native).NotTo(BeNil(), item.Name) + Expect(help).To(ContainSubstring("Permission (any one is enough):"), item.Name) + Expect( + help, + ).To( + ContainSubstring( + "bk-cli monitor permission apply --bk_biz_id --tool "+item.Name, + ), + item.Name, + ) + } + for _, item := range modules { + Expect( + modulePermissionHelp(item.Name), + ).To( + ContainSubstring("Actions used by this module"), + item.Name, + ) + } + }) + + It("returns both apply links for a missing tool permission", func() { + calls := []string{} + server := checkServer( + &calls, + `{"result":true,"data":{"authorized":false,"scopes":[{"tool_name":"get_index_set_fields","state":"missing",`+ + `"legacy_permission":{"system_id":"bk_monitorv3","action_id":"using_log_mcp","resource_type":"space","apply_url":"https://iam.invalid/mcp"},`+ + `"fallback_permission":{"system_id":"bk_log_search","action_id":"search_log_v2","resource_type":"indices","apply_url":"https://iam.invalid/native"}}]}}`, + ) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := newPermissionApplyCmd(systemtest.BuildDeps(false)) + setFlags(cmd, "bk_biz_id", "100100", "tool", "get_index_set_fields", "body", `{"index_set_id":123}`) + + stdout, err := systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + Expect(err).NotTo(HaveOccurred()) + var env map[string]any + Expect(json.Unmarshal([]byte(stdout), &env)).To(Succeed()) + data := env["data"].(map[string]any) + Expect(data["decision"]).To(Equal("apply_required")) + anyOf := data["applications"].([]any)[0].(map[string]any)["any_of"].([]any) + Expect(anyOf).To(HaveLen(2)) + Expect(stdout).To(ContainSubstring("https://iam.invalid/mcp")) + Expect(stdout).To(ContainSubstring("https://iam.invalid/native")) + Expect(calls).NotTo(ContainElement("execute_tool")) + }) + + It("lists permission states without probing every resource", func() { + calls := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, facade(r.URL.Path)) + _, _ = w.Write([]byte(`{"result":true,"data":{"tools":[` + + `{"name":"list_index_sets","permission_state":"granted"},` + + `{"name":"get_index_set_fields","permission_state":"requires_resource"},` + + `{"name":"search_logs","permission_state":"missing"}]}}`)) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := newPermissionListCmd(systemtest.BuildDeps(false)) + setFlags(cmd, "bk_biz_id", "100100", "module", "log") + + stdout, err := systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + Expect(err).NotTo(HaveOccurred()) + Expect(calls).To(Equal([]string{"lookup_tool"})) + Expect(stdout).To(ContainSubstring(`"missing": 1`)) + Expect( + stdout, + ).To( + ContainSubstring("bk-cli monitor permission apply --bk_biz_id 100100 --tool search_logs"), + ) + }) + + It("builds search_logs from a scene, a recent window and repeatable fields", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("search_logs", true) + setFlags( + cmd, + "bk_biz_id", + "100100", + "scene", + "host", + "where", + "serverIp=192.0.2.10", + "query_string", + "ERROR", + "since", + "15m", + "limit", + "5", + "keep_columns", + "dtEventTimeStamp,serverIp", + ) + + args := dryRunArgs(cmd) + Expect(args["target_type"]).To(Equal("scene")) + Expect(args["start_time"]).To(Equal("1799999100")) + Expect(args["end_time"]).To(Equal("1800000000")) + Expect(args["limit"]).To(BeNumerically("==", 5)) + Expect(args["keep_columns"]).To(Equal([]any{"dtEventTimeStamp", "serverIp"})) + Expect(args["table_id_conditions"]).To(HaveLen(1)) + }) + + It("uses milliseconds when the tool contract asks for them", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("execute_sql_query", true) + setFlags(cmd, "bk_biz_id", "100100", "table_id", "x.y", "sql", "select 1", "since", "1h") + + args := dryRunArgs(cmd) + Expect(args["start_time"]).To(Equal("1799996400000")) + Expect(args["end_time"]).To(Equal("1800000000000")) + }) + + It("merges --body and lets named flags override matching keys", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("list_alerts", true) + setFlags( + cmd, + "body", + `{"bk_biz_id":"1","page_size":"50","conditions":[{"key":"severity","value":[1]}]}`, + "bk_biz_id", + "100100", + "status", + "ABNORMAL", + "since", + "1h", + ) + + args := dryRunArgs(cmd) + Expect(args["bk_biz_id"]).To(Equal("100100")) + Expect(args["page_size"]).To(Equal("50")) + Expect(args["status"]).To(Equal([]any{"ABNORMAL"})) + Expect(args["conditions"]).To(HaveLen(1)) + }) + + It("explains a missing log target before sending a request", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("search_logs", false) + setFlags(cmd, "bk_biz_id", "100100", "since", "15m") + stderr := &bytes.Buffer{} + cmd.SetErr(stderr) + + err := cmd.RunE(cmd, nil) + Expect(err.(*output.CLIError).Code).To(Equal("needs_resource")) + Expect(stderr.String()).To(ContainSubstring("bk-cli monitor log list_log_scenes --bk_biz_id 100100")) + }) + + It("builds context from saved search output without changing anchor values", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + entry := tmpDir + "/hit.json" + Expect(os.WriteFile(entry, []byte(`{"ok":true,"data":{"list":[{"__index_set_id__":101003,`+ + `"dtEventTimeStamp":1791600000000,"serverIp":"192.0.2.10","gseIndex":9007199254740993,"iterationIndex":0}]}}`), 0o600)).To(Succeed()) + cmd := toolCommand("search_index_set_context", true) + setFlags(cmd, "bk_biz_id", "100100", "from", entry, "size", "2") + + args := dryRunArgs(cmd) + Expect(args["index_set_id"]).To(BeNumerically("==", 101003)) + Expect(args["gseIndex"]).To(Equal("9007199254740993")) + Expect(args["zero"]).To(BeTrue()) + Expect(args["begin"]).To(Equal("0")) + Expect(args["size"]).To(Equal("2")) + }) + + It("rejects a context anchor from another index set", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + entry := tmpDir + "/hit.json" + Expect( + os.WriteFile( + entry, + []byte( + `{"__index_set_id__":101003,"dtEventTimeStamp":"1","serverIp":"192.0.2.10","gseIndex":"1"}`, + ), + 0o600, + ), + ).To( + Succeed(), + ) + cmd := toolCommand("search_index_set_context", true) + setFlags(cmd, "bk_biz_id", "100100", "from", entry, "index_set_id", "568") + cmd.SetErr(io.Discard) + + err := cmd.RunE(cmd, nil) + Expect(err.(*output.CLIError).Code).To(Equal("anchor_index_mismatch")) + }) + + It("runs permission check as lookup_tool, lookup_tool_schema, then lookup_permissions", func() { + calls := []string{} + server := checkServer( + &calls, + `{"result":true,"data":{"authorized":false,"scopes":[{"tool_name":"get_index_set_fields","state":"requires_resource"}]}}`, + ) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := newPermissionCheckCmd(systemtest.BuildDeps(false)) + setFlags(cmd, "bk_biz_id", "100100", "tool", "get_index_set_fields") + stderr := &bytes.Buffer{} + cmd.SetErr(stderr) + + err := cmd.RunE(cmd, nil) + Expect(err.(*output.CLIError).Code).To(Equal("needs_resource")) + Expect(calls).To(Equal([]string{"lookup_tool", "lookup_tool_schema", "lookup_permissions"})) + Expect(stderr.String()).To(ContainSubstring("bk-cli monitor log list_index_sets --bk_biz_id 100100")) + }) + + It("prints ready without executing the data tool", func() { + calls := []string{} + server := checkServer( + &calls, + `{"result":true,"data":{"authorized":true,"scopes":[{"tool_name":"get_index_set_fields","state":"granted"}]}}`, + ) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := newPermissionCheckCmd(systemtest.BuildDeps(false)) + setFlags( + cmd, + "bk_biz_id", + "100100", + "tool", + "get_index_set_fields", + "body", + `{"index_set_id":3660,"query_string":"secret"}`, + ) + + stdout, err := systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + Expect(err).NotTo(HaveOccurred()) + Expect(stdout).To(ContainSubstring(`"decision": "ready"`)) + Expect(calls).NotTo(ContainElement("execute_tool")) + }) + + It("stops before permission lookup when the read-only contract changes", func() { + calls := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, facade(r.URL.Path)) + _, _ = w.Write( + []byte( + `{"result":true,"data":{"tools":[{"name":"list_index_sets","risk":"mutation","requires_confirmation":true}]}}`, + ), + ) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := newPermissionCheckCmd(systemtest.BuildDeps(false)) + setFlags(cmd, "bk_biz_id", "100100", "tool", "list_index_sets") + cmd.SetErr(io.Discard) + + err := cmd.RunE(cmd, nil) + Expect(err.(*output.CLIError).Code).To(Equal("readonly_contract_changed")) + Expect(calls).To(Equal([]string{"lookup_tool"})) + }) +}) + +func toolCommand(name string, dryRun bool) *cobra.Command { + return newToolCmd(toolsByName[name], systemtest.BuildDeps(dryRun)) +} + +func dryRunArgs(cmd *cobra.Command) map[string]any { + stdout, err := systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + Expect(err).NotTo(HaveOccurred()) + var env map[string]any + Expect(json.Unmarshal([]byte(stdout), &env)).To(Succeed()) + return previewArgs(env) +} + +func checkServer(calls *[]string, permission string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := facade(r.URL.Path) + *calls = append(*calls, name) + switch name { + case "lookup_tool": + _, _ = w.Write( + []byte( + `{"result":true,"data":{"tools":[{"name":"get_index_set_fields","risk":"query","requires_confirmation":false,"permission_state":"requires_resource"}]}}`, + ), + ) + case "lookup_tool_schema": + _, _ = w.Write( + []byte( + `{"result":true,"data":{"catalog_version":"test","permission":{"fallback_permission":{"resource_type":"indices","resource_arg":"index_set_id"}}}}`, + ), + ) + default: + body, _ := io.ReadAll(r.Body) + Expect(string(body)).NotTo(ContainSubstring("query_string")) + _, _ = w.Write([]byte(permission)) + } + })) +} + +func facade(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + return parts[len(parts)-1] +} + +func setFlags(cmd *cobra.Command, values ...string) { + for i := 0; i < len(values); i += 2 { + Expect(cmd.Flags().Set(values[i], values[i+1])).To(Succeed()) + } +} + +// previewArgs reads the business arguments at their documented HTTP location. +func previewArgs(env map[string]any) map[string]any { + request := env["request"].(map[string]any) + if body, ok := request["body"].(map[string]any); ok { + return body + } + return request["params"].(map[string]any) +} + +func readMonitorRequest(r *http.Request) map[string]any { + if r.Method != http.MethodGet { + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + return body + } + result := map[string]any{} + for key, values := range r.URL.Query() { + result[key] = values[0] + for _, param := range toolsByName[facade(r.URL.Path)].Params { + if param.Name == key && param.Type != "string" { + var value any + Expect(json.Unmarshal([]byte(values[0]), &value)).To(Succeed()) + result[key] = value + } + } + } + return result +} diff --git a/cmd/system/monitor/permission.go b/cmd/system/monitor/permission.go new file mode 100644 index 0000000..dffd1a5 --- /dev/null +++ b/cmd/system/monitor/permission.go @@ -0,0 +1,351 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + "github.com/TencentBlueKing/bk-cli/internal/output" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +const permissionQueryLimit = 20 + +func newPermissionSpec() systemcmd.SystemSpec { + return systemcmd.SystemSpec{ + Name: "permission", + Description: "List your permission states, explain one tool, or get IAM apply links", + RegisterGoActions: func(parent *cobra.Command, deps systemcmd.BuildDeps) error { + parent.AddCommand( + newPermissionListCmd(deps), + newPermissionCheckCmd(deps), + newPermissionApplyCmd(deps), + ) + return nil + }, + } +} + +func newPermissionListCmd(deps systemcmd.BuildDeps) *cobra.Command { + var biz, moduleName string + var missingOnly bool + cmd := &cobra.Command{ + Use: "list", + Short: "List your permission state for one business, optionally one module", + Long: `List your own permission state for one business. + +This is a read-only view. requires_resource means a concrete resource is still +needed; it is neither denial nor grant. Use permission check for one resource.`, + Example: " bk-cli monitor permission list --bk_biz_id \n" + + " bk-cli monitor permission list --bk_biz_id --module log --missing_only", + Args: cobra.NoArgs, + RunE: runMonitor(deps, func(cmd *cobra.Command, rt *monitorapi.Runtime) error { + if err := monitorapi.RequireBiz(biz); err != nil { + return err + } + names, err := moduleNames(moduleName) + if err != nil { + return err + } + rows, summary := []map[string]any{}, map[string]int{} + for _, current := range names { + item := moduleByName(current) + reply, err := monitorapi.Call(rt, "lookup_tool", map[string]any{ + "category": item.Category, + "bk_biz_id": biz, + "available_only": false, + "page_size": 100, + }) + if err != nil { + return err + } + if reply.IsDryRun() { + return printReply(cmd, reply, nil) + } + for _, value := range monitorapi.List(monitorapi.Map(reply.Data)["tools"]) { + card := monitorapi.Map(value) + name, _ := card["name"].(string) + if _, ok := toolsByName[name]; !ok { + continue + } + state, _ := card["permission_state"].(string) + summary[state]++ + if missingOnly && state != "missing" { + continue + } + rows = append( + rows, + permissionRow(name, state, biz, rt.ContextName, currentProfile(cmd)), + ) + } + } + sort.Slice( + rows, + func(i, j int) bool { return jsonText(rows[i]["command"]) < jsonText(rows[j]["command"]) }, + ) + data := map[string]any{"bk_biz_id": biz, "summary": summary, "tools": rows} + if summary["missing"] > 0 { + commands := []string{} + for _, row := range rows { + if row["state"] == "missing" { + commands = append(commands, jsonText(row["next"])) + } + } + data["next"] = commands + } + return writeMonitorEnvelope(cmd, &output.Envelope{OK: true, Data: data}) + }), + } + addProfileFlags(cmd) + cmd.Flags().StringVar(&biz, "bk_biz_id", "", "Business ID") + cmd.Flags().StringVar(&moduleName, "module", "", "Module: "+moduleChoices()) + cmd.Flags().BoolVar(&missingOnly, "missing_only", false, "Show only tools explicitly denied") + return cmd +} + +func newPermissionCheckCmd(deps systemcmd.BuildDeps) *cobra.Command { + var biz, name, body string + cmd := &cobra.Command{ + Use: "check", + Short: "Check whether one tool can run without reading data", + Long: `Run lookup_tool, lookup_tool_schema and lookup_permissions. + +Only resource fields declared by the tool permission contract are sent. A ready +result is not a cached grant; the real command is authorized again by bk-monitor.`, + Example: " bk-cli monitor permission check --bk_biz_id --tool get_index_set_fields\n" + + " bk-cli monitor permission check --bk_biz_id --tool get_index_set_fields" + + " --body '{\"index_set_id\":123}'", + Args: cobra.NoArgs, + RunE: runMonitor(deps, func(cmd *cobra.Command, rt *monitorapi.Runtime) error { + values, err := permissionTarget(cmd, biz, name, body) + if err != nil { + return err + } + result, err := check(rt, biz, name, values) + if err != nil { + return err + } + return writeMonitorEnvelope(cmd, &output.Envelope{OK: true, Data: result}) + }), + } + addProfileFlags(cmd) + addPermissionTargetFlags(cmd, &biz, &name, &body) + return cmd +} + +func newPermissionApplyCmd(deps systemcmd.BuildDeps) *cobra.Command { + var biz, name, moduleName, body string + cmd := &cobra.Command{ + Use: "apply", + Short: "Get IAM apply links for missing permissions", + Long: `Get apply links for permissions that bk-monitor explicitly reports as missing. + +This command does not submit an application and does not grant permission. Open +one link in a browser and confirm the request in IAM. Each tool accepts either +the MCP permission or the native SaaS permission.`, + Example: " bk-cli monitor permission apply --bk_biz_id --tool list_alerts\n" + + " bk-cli monitor permission apply --bk_biz_id --module alert", + Args: cobra.NoArgs, + RunE: runMonitor(deps, func(cmd *cobra.Command, rt *monitorapi.Runtime) error { + if (name == "") == (moduleName == "") { + return monitorapi.InputError("invalid_target", "--tool 与 --module 二选一。") + } + if name != "" { + values, err := permissionTarget(cmd, biz, name, body) + if err != nil { + return err + } + return applyTool(cmd, rt, biz, name, values) + } + if body != "" { + return monitorapi.InputError("body_needs_tool", "--body 只用于单个 --tool 的资源上下文。") + } + return applyModule(cmd, rt, biz, moduleName) + }), + } + addProfileFlags(cmd) + addPermissionTargetFlags(cmd, &biz, &name, &body) + cmd.Flags().StringVar(&moduleName, "module", "", "Module: "+moduleChoices()) + return cmd +} + +func applyTool(cmd *cobra.Command, rt *monitorapi.Runtime, biz, name string, values map[string]any) error { + result, err := check(rt, biz, name, values) + if err == nil { + result["applications"] = []any{} + result["meaning"] = "服务端判定已有权限,无需申请。" + return writeMonitorEnvelope(cmd, &output.Envelope{OK: true, Data: result}) + } + decision, ok := monitorapi.IsJudgment(err) + if !ok || decision.Code != "permission_missing" { + return err + } + return writeMonitorEnvelope(cmd, &output.Envelope{OK: true, Data: map[string]any{ + "decision": "apply_required", + "bk_biz_id": biz, + "tool": name, + "request_id": decision.RequestID, + "applications": []any{map[string]any{"tool": name, "any_of": decision.AnyOf}}, + "next": "在浏览器打开任一 apply_url 并在 IAM 确认申请;审批通过后重新执行原命令。", + }}) +} + +func applyModule(cmd *cobra.Command, rt *monitorapi.Runtime, biz, moduleName string) error { + if err := monitorapi.RequireBiz(biz); err != nil { + return err + } + if _, err := moduleNames(moduleName); err != nil { + return err + } + item := moduleByName(moduleName) + reply, err := monitorapi.Call(rt, "lookup_tool", map[string]any{ + "category": item.Category, "bk_biz_id": biz, "available_only": false, "page_size": 100, + }) + if err != nil || reply.IsDryRun() { + return printReply(cmd, reply, err) + } + var missing, unresolved []string + for _, value := range monitorapi.List(monitorapi.Map(reply.Data)["tools"]) { + card := monitorapi.Map(value) + name, _ := card["name"].(string) + if _, ok := toolsByName[name]; !ok { + continue + } + switch card["permission_state"] { + case "missing": + missing = append(missing, name) + case "requires_resource": + unresolved = append(unresolved, name) + } + } + sort.Strings(missing) + sort.Strings(unresolved) + if len(missing) > permissionQueryLimit { + return monitorapi.InputError("too_many_missing", "缺权工具超过 20 个;请缩小到单个 --tool。") + } + applications := []any{} + for _, name := range missing { + _, err := check(rt, biz, name, map[string]any{}) + if decision, ok := monitorapi.IsJudgment(err); ok && decision.Code == "permission_missing" { + applications = append( + applications, + map[string]any{ + "tool": name, + "request_id": decision.RequestID, + "any_of": decision.AnyOf, + }, + ) + } else if err != nil { + return err + } + } + data := map[string]any{ + "bk_biz_id": biz, "module": moduleName, "applications": applications, + "requires_resource": unresolved, + } + if len(applications) == 0 { + data["decision"] = "no_business_level_application" + data["meaning"] = "该模块没有业务级缺权。requires_resource 工具需要按具体资源运行 permission apply --tool。" + } else { + data["decision"] = "apply_required" + data["next"] = "在浏览器打开任一 apply_url 并在 IAM 确认申请;审批通过后重新执行原命令。" + } + return writeMonitorEnvelope(cmd, &output.Envelope{OK: true, Data: data}) +} + +func permissionRow(name, state, biz, contextName string, p *selectedProfile) map[string]any { + row := map[string]any{"tool": name, "command": commandName(name), "state": state} + switch state { + case "missing": + row["next"] = permissionHint(contextName, "apply", name, biz, nil) + profileSuffix(p) + case "requires_resource": + row["next"] = permissionHint(contextName, "check", name, biz, nil) + profileSuffix(p) + } + return row +} + +func permissionTarget(cmd *cobra.Command, biz, name, body string) (map[string]any, error) { + if err := monitorapi.RequireBiz(biz); err != nil { + return nil, err + } + if _, ok := toolsByName[name]; !ok { + return nil, monitorapi.InputError( + "invalid_tool", + "未知的只读工具;先运行 bk-cli monitor permission list --bk_biz_id "+biz+"。", + ) + } + values := map[string]any{} + if body != "" { + raw, err := readBody(cmd, body) + if err != nil { + return nil, err + } + if err := jsonObject(raw, &values); err != nil { + return nil, err + } + } + if err := profileArgs(currentProfile(cmd), toolsByName[name], values, "", nil); err != nil { + return nil, err + } + if value, exists := values["bk_biz_id"]; exists && jsonText(value) != biz { + return nil, monitorapi.InputError("conflicting_business", "--body 的业务与 --bk_biz_id 不一致。") + } + return values, nil +} + +func addPermissionTargetFlags(cmd *cobra.Command, biz, name, body *string) { + cmd.Flags().StringVar(biz, "bk_biz_id", "", "Business ID") + cmd.Flags().StringVar(name, "tool", "", "Monitor API tool name") + cmd.Flags().StringVar(body, "body", "", "Resource fields as JSON, @file, or -; only permission fields are sent") +} + +func moduleNames(value string) ([]string, error) { + if value == "" { + names := make([]string, 0, len(modules)) + for _, item := range modules { + names = append(names, item.Name) + } + return names, nil + } + if moduleByName(value).Name == "" { + return nil, monitorapi.InputError("invalid_module", "--module 可选:"+moduleChoices()) + } + return []string{value}, nil +} + +func moduleByName(name string) module { + for _, item := range modules { + if item.Name == name { + return item + } + } + return module{} +} + +func moduleChoices() string { + names := make([]string, 0, len(modules)) + for _, item := range modules { + names = append(names, item.Name) + } + return strings.Join(names, ", ") +} diff --git a/cmd/system/monitor/permission_help.go b/cmd/system/monitor/permission_help.go new file mode 100644 index 0000000..2fc0a8d --- /dev/null +++ b/cmd/system/monitor/permission_help.go @@ -0,0 +1,113 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "fmt" + "sort" + "strings" +) + +// permissionLines describes the reviewed metadata in help text. The live decision always comes from the server. +func permissionLines(item tool) []string { + spec := item.Permission + if spec.Mode == "exempt" { + return []string{"Permission: platform-visible metadata; no business permission is required."} + } + lines := []string{"Permission (any one is enough):"} + if spec.MCP != nil { + lines = append(lines, " 1. "+describeAction(*spec.MCP)+" on "+resourceLabel(spec.MCP.ResourceType)) + } + if spec.Native != nil { + line := " 2. " + describeAction( + spec.Native.iamAction, + ) + " on " + resourceLabel( + spec.Native.ResourceType, + ) + if spec.Native.Conditional != nil { + c := spec.Native.Conditional + line += fmt.Sprintf( + "; when %s: %s on %s", + c.When, + describeAction(c.iamAction), + resourceLabel(c.ResourceType), + ) + } + lines = append(lines, line) + } + lines = append(lines, + "Check: bk-cli monitor permission check --bk_biz_id --tool "+item.Name, + "Apply: bk-cli monitor permission apply --bk_biz_id --tool "+item.Name, + ) + return lines +} + +func describeAction(action iamAction) string { + key := action.SystemID + ":" + action.ActionID + if name := catalogData.Actions[key]; name != "" { + return fmt.Sprintf("%s (%s)", name, key) + } + return key +} + +func resourceLabel(kind string) string { + if label := catalogData.ResourceTypes[kind]; label != "" { + return label + } + return kind +} + +// moduleActions returns the distinct actions used by a module, for module-level permission guidance. +func moduleActions(moduleName string) []string { + seen := map[string]bool{} + for _, item := range catalogData.Tools { + if item.Module != moduleName { + continue + } + if item.Permission.MCP != nil { + seen["MCP: "+describeAction(*item.Permission.MCP)] = true + } + if item.Permission.Native != nil { + seen["Native: "+describeAction(item.Permission.Native.iamAction)] = true + if c := item.Permission.Native.Conditional; c != nil { + seen["Native: "+describeAction(c.iamAction)] = true + } + } + } + values := make([]string, 0, len(seen)) + for value := range seen { + values = append(values, value) + } + sort.Strings(values) + return values +} + +func modulePermissionHelp(moduleName string) string { + actions := moduleActions(moduleName) + if len(actions) == 0 { + return "Permission: platform-visible metadata; no business permission is required." + } + return strings.Join([]string{ + "Permission: MCP permission is checked first; native SaaS permission is checked only after explicit denial.", + "Actions used by this module:", + " " + strings.Join(actions, "\n "), + "List: bk-cli monitor permission list --bk_biz_id --module " + moduleName, + "Apply: bk-cli monitor permission apply --bk_biz_id --module " + moduleName, + }, "\n") +} diff --git a/cmd/system/monitor/permission_legacy_test.go b/cmd/system/monitor/permission_legacy_test.go new file mode 100644 index 0000000..b14dfbc --- /dev/null +++ b/cmd/system/monitor/permission_legacy_test.go @@ -0,0 +1,135 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "bytes" + "net/http" + "net/http/httptest" + + json "github.com/goccy/go-json" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + systemtest "github.com/TencentBlueKing/bk-cli/cmd/system/testutil" +) + +// All IDs below are synthetic and are used only by the local HTTP fixtures. +var _ = Describe("Legacy space permission responses", func() { + DescribeTable( + "requires an explicit decision for the exact tool, action and business", + func(change func(map[string]any, map[string]any), valid bool) { + permission := map[string]any{ + "action_id": "using_metrics_mcp", + "resource_type": "space", + "resource_arg": "bk_biz_id", + } + scope := map[string]any{ + "authorized": true, + "action_id": "using_metrics_mcp", + "resource": map[string]any{"bk_biz_id": "100100"}, + } + change(scope, permission) + result := normalizeLegacyPermissionState(scope, permission, "100100") + Expect(result["state"] == "granted" || result["state"] == "missing").To(Equal(valid)) + Expect(scope).NotTo(HaveKey("state")) + }, + Entry("grant", func(_, _ map[string]any) {}, true), + Entry("denial", func(s, _ map[string]any) { s["authorized"] = false }, true), + Entry("missing decision", func(s, _ map[string]any) { delete(s, "authorized") }, false), + Entry("nonboolean decision", func(s, _ map[string]any) { s["authorized"] = "true" }, false), + Entry( + "wrong business", + func(s, _ map[string]any) { s["resource"] = map[string]any{"bk_biz_id": 200200} }, + false, + ), + Entry("missing business", func(s, _ map[string]any) { delete(s, "resource") }, false), + Entry("wrong action", func(s, _ map[string]any) { s["action_id"] = "other" }, false), + Entry("native schema", func(_, p map[string]any) { p["mode"] = "mcp_or_native" }, false), + Entry("missing schema action", func(_, p map[string]any) { delete(p, "action_id") }, false), + Entry("resource scope", func(_, p map[string]any) { p["resource_type"] = "indices" }, false), + ) + + DescribeTable("checks the complete preflight without executing SQL", + func(summary, scopeAuth bool, scopeBiz string, wantReady bool) { + calls := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := facade(r.URL.Path) + calls = append(calls, name) + var data any + switch name { + case "lookup_tool": + data = map[string]any{ + "tools": []any{ + map[string]any{ + "name": "execute_sql_query", + "risk": "query", + "requires_confirmation": false, + }, + }, + } + case "lookup_tool_schema": + data = map[string]any{ + "permission": map[string]any{ + "action_id": "using_metrics_mcp", + "resource_type": "space", + "resource_arg": "bk_biz_id", + }, + } + case "lookup_permissions": + data = map[string]any{ + "authorized": summary, + "scopes": []any{ + map[string]any{ + "tool_name": "execute_sql_query", + "action_id": "using_metrics_mcp", + "authorized": scopeAuth, + "resource": map[string]any{"bk_biz_id": scopeBiz}, + }, + }, + } + default: + Fail("preflight must not execute a business request") + } + Expect( + json.NewEncoder(w).Encode(map[string]any{"result": true, "data": data}), + ).To( + Succeed(), + ) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := newPermissionCheckCmd(systemtest.BuildDeps(false)) + setFlags(cmd, "bk_biz_id", "100100", "tool", "execute_sql_query") + cmd.SetErr(&bytes.Buffer{}) + _, err := systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + if wantReady { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + Expect(calls).To(Equal([]string{"lookup_tool", "lookup_tool_schema", "lookup_permissions"})) + }, + Entry("consistent grant", true, true, "100100", true), + Entry("consistent denial", false, false, "100100", false), + Entry("conflicting grant", true, false, "100100", false), + Entry("conflicting denial", false, true, "100100", false), + Entry("wrong business", true, true, "200200", false), + ) +}) diff --git a/cmd/system/monitor/profile.go b/cmd/system/monitor/profile.go new file mode 100644 index 0000000..205df37 --- /dev/null +++ b/cmd/system/monitor/profile.go @@ -0,0 +1,368 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strconv" + "strings" + + "go.yaml.in/yaml/v3" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + "github.com/TencentBlueKing/bk-cli/internal/config" + "github.com/TencentBlueKing/bk-cli/internal/validate" +) + +// Profiles contain resource bindings, never credentials, executable templates or grants. +// Keep the format versioned and strict: a typo must not silently broaden a query. +type profileFile struct { + Version int `yaml:"version" json:"version"` + Sources map[string]profileSource `yaml:"sources,omitempty" json:"sources,omitempty"` + Profiles map[string]queryProfile `yaml:"profiles" json:"profiles"` +} + +type queryProfile struct { + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Environment string `yaml:"environment,omitempty" json:"environment,omitempty"` + Components map[string]profileComponent `yaml:"components,omitempty" json:"components,omitempty"` + Context string `yaml:"context,omitempty" json:"context,omitempty"` + BizID string `yaml:"bk_biz_id,omitempty" json:"bk_biz_id,omitempty"` + Log *logProfile `yaml:"log,omitempty" json:"log,omitempty"` + Metric *metricProfile `yaml:"metric,omitempty" json:"metric,omitempty"` + APM *apmProfile `yaml:"apm,omitempty" json:"apm,omitempty"` +} + +type logProfile struct { + IndexSetID int64 `yaml:"index_set_id,omitempty" json:"index_set_id,omitempty"` + Scene string `yaml:"scene,omitempty" json:"scene,omitempty"` + Where map[string]string `yaml:"where,omitempty" json:"where,omitempty"` + Filters map[string]string `yaml:"filters,omitempty" json:"filters,omitempty"` + Conditions map[string]any `yaml:"conditions,omitempty" json:"conditions,omitempty"` +} + +type metricProfile struct { + TableID string `yaml:"table_id" json:"table_id"` +} + +type apmProfile struct { + AppName string `yaml:"app_name" json:"app_name"` + ServiceName string `yaml:"service_name,omitempty" json:"service_name,omitempty"` +} + +type selectedProfile struct { + Name string `json:"name"` + File string `json:"file"` + SHA256 string `json:"sha256"` + Profile queryProfile `json:"-"` + Applied []string `json:"applied_fields"` + Resolved map[string]any `json:"resolved_parameters"` + RowFilters map[string]string `json:"-"` + Version int `json:"version"` + Definition *queryProfile `json:"-"` + FileSpec *profileFile `json:"-"` + Component string `json:"component,omitempty"` + Binding string `json:"binding,omitempty"` + Source string `json:"source,omitempty"` + Route *profileBinding `json:"-"` + ToolArgs map[string]any `json:"-"` + Ignored []string `json:"ignored_fields,omitempty"` +} + +var profileNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$`) + +func profileFilePath(explicit string) (string, error) { + if explicit == "" { + explicit = os.Getenv("BK_CLI_MONITOR_PROFILE_FILE") + } + if explicit == "" { + explicit = filepath.Join(config.BaseDirectory(), "monitor", "profiles.yaml") + } + path, err := filepath.Abs(explicit) + if err != nil { + return "", monitorapi.InputError("invalid_profile_file", "无法解析 Profile 文件路径。") + } + return path, nil +} + +func loadProfiles(path string) (*profileFile, string, error) { + raw, err := readFileLimited(path) + if err != nil { + return nil, "", monitorapi.InputError( + "invalid_profile_file", + "Profile 文件不可读、为空或超过 64 KiB;检查 --profile_file。", + ) + } + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(true) + var file profileFile + // Do not echo YAML parser errors: they may contain values mistakenly used as credentials. + if err := decoder.Decode(&file); err != nil { + return nil, "", monitorapi.InputError( + "invalid_profile_file", + "Profile YAML 格式、类型或字段不合法;支持 version、profiles 及文档列出的资源字段。", + ) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return nil, "", monitorapi.InputError("invalid_profile_file", "Profile 文件只允许一个 YAML 文档。") + } + if (file.Version != 1 && file.Version != 2) || len(file.Profiles) == 0 || len(file.Profiles) > 100 { + return nil, "", monitorapi.InputError( + "invalid_profile_file", + "需要 version: 1 或 2,且 profiles 包含 1–100 个具名配置。", + ) + } + if file.Version == 2 { + if err := validateV2(&file); err != nil { + return nil, "", err + } + return &file, fmt.Sprintf("%x", sha256.Sum256(raw)), nil + } + if len(file.Sources) > 0 { + return nil, "", monitorapi.InputError("invalid_profile_file", "sources 需要 version: 2。") + } + for _, name := range sortedProfileNames(&file) { + if !profileNamePattern.MatchString(name) { + return nil, "", monitorapi.InputError( + "invalid_profile_name", + "Profile 名称限 1–128 个字母、数字、点、下划线或连字符,首字符为字母或数字。", + ) + } + if err := validateProfile(file.Profiles[name]); err != nil { + return nil, "", err + } + } + return &file, fmt.Sprintf("%x", sha256.Sum256(raw)), nil +} + +func sortedProfileNames(file *profileFile) []string { + names := make([]string, 0, len(file.Profiles)) + for name := range file.Profiles { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func validateProfile(p queryProfile) error { + invalid := func(message string) error { return monitorapi.InputError("invalid_profile", message) } + if len(p.Components) > 0 || p.Environment != "" { + return invalid("components/environment 需要 version: 2。") + } + if validate.ValidateContextName(p.Context) != nil { + return invalid("每个 Profile 必须引用合法的 bk-cli context 名称;不允许 URL 或凭据。") + } + biz, err := strconv.ParseInt(p.BizID, 10, 64) + if err != nil || biz == 0 { + return invalid("Profile 的 bk_biz_id 必须是非零整数(允许负数空间 ID)。") + } + if p.Log != nil { + l := p.Log + if err := validateLogFilters(l.Filters); err != nil { + return err + } + if (l.IndexSetID > 0) == (l.Scene != "") || l.IndexSetID < 0 { + return invalid("log 必须二选一:正整数 index_set_id,或 scene;不允许同时配置。") + } + if len(l.Where) > 0 && l.Scene == "" { + return invalid("log.where 只用于 scene 模式;索引集过滤请用 log.conditions。") + } + if len(l.Conditions) > 0 && l.IndexSetID == 0 { + return invalid("log.conditions 只用于 index_set_id 模式。") + } + for key, value := range l.Where { + if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" || key == "scene" { + return invalid("log.where 需要非空的场景维度和值;scene 请放在 log.scene。") + } + } + } + if p.Metric != nil && strings.TrimSpace(p.Metric.TableID) == "" { + return invalid("metric.table_id 不可为空。") + } + if p.APM != nil && strings.TrimSpace(p.APM.AppName) == "" { + return invalid("apm.app_name 不可为空。") + } + return nil +} + +// profileArgs fills only parameters accepted by this exact tool. Explicit args win, +// but a business or log-mode change must not reuse identifiers from a different scope. +func profileArgs(selection *selectedProfile, item tool, args map[string]any, scene string, where []string) error { + if selection == nil { + return nil + } + p := selection.Profile + if value, exists := args["bk_biz_id"]; exists && jsonText(value) != p.BizID { + return monitorapi.InputError( + "profile_business_mismatch", + "显式业务与 Profile 不一致;请选择对应 Profile 或移除 --profile。", + ) + } + fill := func(key string, value any) { fillProfileArgument(selection, item, args, key, value) } + fill("bk_biz_id", p.BizID) + applyBindingArgs(selection, item, args) + if p.Log != nil && (item.Module == "log" || item.Name == "get_log_index_set") { + l := p.Log + if l.Scene != "" { + fill("scene", l.Scene) + } + switch { + case l.IndexSetID > 0: + if scene != "" || args["table_id_conditions"] != nil || args["target_type"] == "scene" { + return monitorapi.InputError( + "profile_log_mode_mismatch", + "索引集 Profile 不能与场景查询混用;请换 Profile 或移除 --profile。", + ) + } + fill("index_set_id", l.IndexSetID) + if item.Name == "search_logs" && len(l.Conditions) > 0 { + fill("conditions", l.Conditions) + } + case l.Scene != "" && hasParam(item, "index_set_id") && !hasParam(item, "table_id_conditions"): + return monitorapi.InputError( + "profile_log_mode_mismatch", + "此工具需要索引集,当前 Profile 是场景模式;不会自动切换数据源。", + ) + case l.Scene != "" && hasParam(item, "table_id_conditions"): + if args["index_set_id"] != nil || args["target_type"] == "index_set" { + return monitorapi.InputError("profile_log_mode_mismatch", "场景 Profile 不能与索引集查询混用。") + } + if scene != "" && scene != l.Scene { + return monitorapi.InputError( + "profile_log_mode_mismatch", + "显式 scene 与 Profile 不一致;请换 Profile 或移除 --profile。", + ) + } + // Explicit table_id_conditions replace the entire route; do not deep-merge boolean logic. + if _, exists := args["table_id_conditions"]; exists && (scene != "" || len(where) > 0) { + return monitorapi.InputError( + "conflicting_target", + "显式 table_id_conditions 不能与 --scene/--where 同时使用。", + ) + } + if _, exists := args["table_id_conditions"]; !exists { + conditions, err := sceneConditions(l.Scene, l.Where, where) + if err != nil { + return err + } + fill("table_id_conditions", conditions) + } + fill("target_type", "scene") + } + } + for key, value := range p.moduleDefaults(item.Module) { + fill(key, value) + } + sort.Strings(selection.Applied) + recordProfileArgs(selection, args) + return nil +} + +func applyBindingArgs(selection *selectedProfile, item tool, args map[string]any) { + for key, value := range selection.ToolArgs { + if hasParam(item, key) { + fillProfileArgument(selection, item, args, key, value) + } else { + selection.Ignored = append(selection.Ignored, key) + } + } + sort.Strings(selection.Ignored) +} + +func fillProfileArgument(selection *selectedProfile, item tool, args map[string]any, key string, value any) { + if _, exists := args[key]; exists || !hasParam(item, key) { + return + } + args[key] = value + if !slices.Contains(selection.Applied, key) { + selection.Applied = append(selection.Applied, key) + } +} + +func recordProfileArgs(selection *selectedProfile, args map[string]any) { + if selection == nil { + return + } + sort.Strings(selection.Applied) + selection.Resolved = map[string]any{} + if len(selection.RowFilters) > 0 { + selection.Resolved["log_filters"] = selection.RowFilters + } + keys := []string{ + "bk_biz_id", "index_set_id", "table_id", "table_id_conditions", + "target_type", "conditions", "app_name", "service_name", "service_names", + } + for _, key := range keys { + if value, ok := args[key]; ok { + selection.Resolved[key] = value + } + } +} + +func (p queryProfile) moduleDefaults(module string) map[string]any { + args := map[string]any{} + if p.Metric != nil && module == "metric" { + args["table_id"] = p.Metric.TableID + } + if p.APM != nil && module == "apm" { + args["app_name"] = p.APM.AppName + if p.APM.ServiceName != "" { + args["service_name"] = p.APM.ServiceName + args["service_names"] = []any{p.APM.ServiceName} + } + } + return args +} + +func sceneConditions(scene string, defaults map[string]string, where []string) ([]any, error) { + filters := map[string]string{} + maps.Copy(filters, defaults) + for _, raw := range where { + key, value, ok := strings.Cut(raw, "=") + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + if !ok || key == "" || value == "" || key == "scene" { + return nil, monitorapi.InputError("invalid_where", "--where 使用非空 KEY=VALUE;scene 使用 --scene。") + } + filters[key] = value + } + keys := make([]string, 0, len(filters)) + for key := range filters { + keys = append(keys, key) + } + sort.Strings(keys) + conditions := []any{map[string]any{"field_name": "scene", "value": []any{scene}, "op": "eq"}} + for _, key := range keys { + conditions = append( + conditions, + map[string]any{"field_name": key, "value": []any{filters[key]}, "op": "eq"}, + ) + } + return []any{conditions}, nil +} diff --git a/cmd/system/monitor/profile_commands.go b/cmd/system/monitor/profile_commands.go new file mode 100644 index 0000000..3eed3ee --- /dev/null +++ b/cmd/system/monitor/profile_commands.go @@ -0,0 +1,281 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + "github.com/TencentBlueKing/bk-cli/internal/output" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +func newProfileSpec() systemcmd.SystemSpec { + return systemcmd.SystemSpec{ + Name: "profile", Description: "Inspect and check SRE-maintained observability profiles", + RegisterGoActions: func(parent *cobra.Command, deps systemcmd.BuildDeps) error { + parent.AddCommand(newProfileListCmd(), newProfileShowCmd(deps), newProfileCheckCmd(deps)) + return nil + }, + } +} + +func newProfileListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", Short: "List profile names and scopes locally, without credentials or network", + Example: " bk-cli monitor profile list --profile_file ./profiles.yaml", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + pathFlag, _ := cmd.Flags().GetString("profile_file") + path, err := profileFilePath(pathFlag) + if err != nil { + return printMonitorFailure(cmd, err) + } + file, hash, err := loadProfiles(path) + if err != nil { + return printMonitorFailure(cmd, err) + } + rows := []map[string]any{} + for _, name := range sortedProfileNames(file) { + p := file.Profiles[name] + if file.Version == 2 { + rows = append( + rows, + map[string]any{ + "name": name, + "description": p.Description, + "environment": p.Environment, + "components": sortedKeys(p.Components), + }, + ) + continue + } + rows = append( + rows, + map[string]any{ + "name": name, + "description": p.Description, + "context": p.Context, + "bk_biz_id": p.BizID, + }, + ) + } + return output.WriteCommandEnvelope( + cmd, + output.SuccessData( + map[string]any{ + "version": file.Version, + "file": path, + "sha256": hash, + "profiles": rows, + }, + ), + ) + }, + } + output.AddJQFlag(cmd) + cmd.Flags().String("profile_file", "", "Profiles YAML file (or BK_CLI_MONITOR_PROFILE_FILE)") + return cmd +} + +func newProfileShowCmd(deps systemcmd.BuildDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "show", + Short: "Show one resolved profile locally; no runtime, credentials or network needed", + Example: " bk-cli monitor profile show --profile game-prod --profile_file ./profiles.yaml", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + _, err := selectProfile(cmd, deps) + if err != nil { + return printMonitorFailure(cmd, err) + } + p := currentProfile(cmd) + if p == nil { + return printMonitorFailure( + cmd, + monitorapi.InputError("needs_profile", "请用 --profile 指定名称。"), + ) + } + if p.Version == 2 { + return writeMonitorEnvelope(cmd, output.SuccessData(v2ProfileView(p))) + } + return writeMonitorEnvelope(cmd, output.SuccessData(p.Profile)) + }, + } + addProfileFlags(cmd) + return cmd +} + +func newProfileCheckCmd(deps systemcmd.BuildDeps) *cobra.Command { + cmd := &cobra.Command{ + Use: "check", + Short: "Preflight configured resources and permissions without reading logs or metrics", + Long: `Check a representative read-only tool and metadata for each configured resource. +This does not read log bodies or metric series. It is not a cached grant or validation +of every query/filter; the server authorizes each real call again.`, + Example: " bk-cli monitor profile check --profile game-prod", + Args: cobra.NoArgs, + RunE: runMonitor(deps, func(cmd *cobra.Command, rt *monitorapi.Runtime) error { + p := currentProfile(cmd) + if p == nil { + return monitorapi.InputError("needs_profile", "请用 --profile 指定名称。") + } + if rt.DryRun { + reply, err := monitorapi.Call( + rt, + "lookup_tool", + map[string]any{ + "tool_name": profileProbeTools(p.Profile)[0], + "bk_biz_id": p.Profile.BizID, + "available_only": false, + }, + ) + return printReply(cmd, reply, err) + } + rows, exit := checkProfile(rt, p) + return writeDiagnostic( + cmd, + map[string]any{ + "ok": exit == 0, + "checks": rows, + "meaning": "预检不读取日志或指标,不缓存授权,也不证明所有查询和过滤条件有效。", + }, + exit, + ) + }), + } + addProfileFlags(cmd) + return cmd +} + +func profileProbeTools(p queryProfile) []string { + names := []string{} + if p.Log != nil { + if p.Log.Scene != "" { + names = append(names, "search_logs") + } else { + names = append(names, "get_index_set_fields") + } + } + if p.Metric != nil { + names = append(names, "list_time_series_metrics") + } + if p.APM != nil { + names = append(names, "list_apm_services") + } + if len(names) == 0 { + names = append(names, "list_index_sets") + } + return names +} + +func checkProfile(rt *monitorapi.Runtime, p *selectedProfile) ([]map[string]any, int) { + rows := []map[string]any{} + exit := 0 + for _, name := range profileProbeTools(p.Profile) { + values := map[string]any{} + // Work on a copy; check must not claim these were applied to a business query. + copy := *p + copy.Applied = nil + err := profileArgs(©, toolsByName[name], values, "", nil) + var result map[string]any + if err == nil { + result, err = check(rt, p.Profile.BizID, name, values) + } + if err == nil { + // Permission for metric/scene tools can be business-scoped. Read only + // resource metadata as well; a granted business is not proof a table exists. + metadataTool := name + if name == "search_logs" { + metadataTool = "get_scene_log_fields" + } + if p.Profile.Log != nil || p.Profile.Metric != nil || p.Profile.APM != nil { + metadataArgs := map[string]any{} + err = profileArgs(©, toolsByName[metadataTool], metadataArgs, "", nil) + if err == nil { + var reply *monitorapi.Reply + reply, err = monitorapi.Execute(rt, metadataTool, metadataArgs) + if err == nil { + rows = append(rows, map[string]any{ + "tool": name, + "status": "pass", + "decision": "ready", + "steps": result["steps"], + "metadata_tool": metadataTool, + "request_id": reply.RequestID, + }) + } + } + } else { + rows = append( + rows, + map[string]any{ + "tool": name, + "status": "pass", + "decision": "ready", + "steps": result["steps"], + }, + ) + } + if err == nil { + continue + } + } + row, code := diagnosticFailure(name, err) + row["tool"] = name + row["next"] = profileDiagnosticNext(row, name, p) + rows = append(rows, row) + if code > exit { + exit = code + } + } + return rows, exit +} + +// Diagnostics always print their report, and return a nonzero exit if any check failed. +func writeDiagnostic(cmd *cobra.Command, data map[string]any, exit int) error { + if err := writeMonitorEnvelope(cmd, &output.Envelope{OK: exit == 0, Data: data}); err != nil { + return err + } + if exit != 0 { + return &output.CLIError{Code: "monitor_check_failed", Message: "一个或多个检查失败;查看 checks。", ExitCode: exit} + } + return nil +} + +func diagnosticFailure(name string, err error) (map[string]any, int) { + row := map[string]any{"name": name, "status": "fail", "decision": "request_failed"} + var decision *monitorapi.Decision + if errors.As(err, &decision) { + row["decision"], row["message"] = decision.Code, decision.Message + row["request_id"], row["next"] = decision.RequestID, decision.Next + if len(decision.AnyOf) > 0 { + row["any_of_permissions"] = decision.AnyOf + } + return row, decision.ExitCode + } + var cli *output.CLIError + if errors.As(err, &cli) { + row["decision"] = cli.Code + return row, cli.ExitCode + } + // Never include upstream error text or credentials in diagnostics. + return row, output.ExitCodeSystemError +} diff --git a/cmd/system/monitor/profile_routes.go b/cmd/system/monitor/profile_routes.go new file mode 100644 index 0000000..7fbd915 --- /dev/null +++ b/cmd/system/monitor/profile_routes.go @@ -0,0 +1,205 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "context" + "strings" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +func profileInspection(cmd *cobra.Command) bool { + return cmd.Name() == "status" || cmd.Name() == "show" || + (cmd.Name() == "check" && cmd.Flags().Lookup("tool") == nil) +} + +func requestedProfileModule(cmd *cobra.Command) (string, string) { + if item, ok := toolsByName[cmd.Name()]; ok { + return item.Module, item.Name + } + if name, _ := cmd.Flags().GetString("tool"); name != "" { + if item, ok := toolsByName[name]; ok { + return item.Module, name + } + } + for _, flag := range []string{"module", "category"} { + if value, _ := cmd.Flags().GetString(flag); value != "" { + for _, m := range modules { + if m.Name == value || m.Category == value { + return m.Name, "" + } + } + } + } + return "", "" +} + +func selectV2Profile(cmd *cobra.Command, p *selectedProfile) error { + component, _ := cmd.Flags().GetString("component") + binding, _ := cmd.Flags().GetString("binding") + if component == "" && len(p.Definition.Components) == 1 { + component = sortedKeys(p.Definition.Components)[0] + } + if component == "" { + if profileInspection(cmd) && binding == "" { + return nil + } + return monitorapi.InputError( + "needs_component", + "请用 --component 选择业务组件:"+strings.Join(sortedKeys(p.Definition.Components), ", "), + ) + } + c, ok := p.Definition.Components[component] + if !ok { + return monitorapi.InputError("component_not_found", "Profile 中不存在该 component。") + } + p.Component = component + module, toolName := requestedProfileModule(cmd) + if binding == "" { + if profileInspection(cmd) { + return nil + } + candidates := []string{} + for _, name := range sortedKeys(c.Bindings) { + if module == "" || c.Bindings[name].Module == module { + candidates = append(candidates, name) + } + } + if len(candidates) == 0 { + return monitorapi.InputError("profile_binding_missing", "该组件没有此 module 的绑定;不会回退到其他组件或当前默认环境。") + } + if len(candidates) != 1 { + return monitorapi.InputError( + "needs_binding", + "请用 --binding 明确选择:"+strings.Join(candidates, ", "), + ) + } + binding = candidates[0] + } + b, ok := c.Bindings[binding] + if !ok { + return monitorapi.InputError("binding_not_found", "该 component 中不存在此 binding。") + } + if module != "" && module != b.Module { + return monitorapi.InputError("profile_binding_mismatch", "binding.module 与当前命令不一致;不会切换到其他能力。") + } + if err := activateBinding(p, component, binding, toolName); err != nil { + return err + } + // Business-scoped discovery must stay on this binding's module, not enumerate other sources. + for _, flagName := range []string{"module", "category"} { + if flag := cmd.Flags().Lookup(flagName); flag != nil && !flag.Changed { + value := b.Module + if flagName == "category" { + value = categoryFor(value) + } + if err := flag.Value.Set(value); err != nil { + return err + } + } + } + return nil +} + +func activateBinding(p *selectedProfile, component, binding, toolName string) error { + b := p.Definition.Components[component].Bindings[binding] + source := p.FileSpec.Sources[b.Source] + values := bindingArgs(b, toolName) + log, err := bindingLog(b, values) + if err != nil { + return err + } + p.Component, p.Binding, p.Source, p.Route = component, binding, b.Source, &b + p.Profile = queryProfile{Context: source.Context, BizID: source.BizID, Log: log} + p.ToolArgs = values + p.Applied = []string{} + p.Ignored = nil + p.Resolved = nil + p.RowFilters = nil + return nil +} + +func bindProfileRuntime(cmd *cobra.Command, deps systemcmd.BuildDeps, p *selectedProfile) (systemcmd.BuildDeps, error) { + if deps.GetContext != nil && deps.GetContext() != "" && deps.GetContext() != p.Profile.Context { + return deps, monitorapi.InputError( + "profile_context_mismatch", + "--context 与所选绑定的 context 不一致;不会跨环境复用资源 ID。", + ) + } + if flag := cmd.Flags().Lookup("bk_biz_id"); flag != nil { + if flag.Changed && flag.Value.String() != p.Profile.BizID { + return deps, monitorapi.InputError("profile_business_mismatch", "--bk_biz_id 与所选绑定的数据来源业务不一致。") + } + if !flag.Changed { + if err := flag.Value.Set(p.Profile.BizID); err != nil { + return deps, err + } + p.Applied = append(p.Applied, "bk_biz_id") + p.Resolved = map[string]any{"bk_biz_id": p.Profile.BizID} + } + } + deps.GetContext = func() string { return p.Profile.Context } + return deps, nil +} + +func cloneBinding( + cmd *cobra.Command, + p *selectedProfile, + component, binding, toolName string, +) (*selectedProfile, error) { + copy := *p + if err := activateBinding(©, component, binding, toolName); err != nil { + return nil, err + } + cmd.SetContext(context.WithValue(cmd.Context(), profileContextKey{}, ©)) + return ©, nil +} + +func v2ProfileView(p *selectedProfile) map[string]any { + components := map[string]any{} + for _, name := range sortedKeys(p.Definition.Components) { + if p.Component != "" && p.Component != name { + continue + } + c := p.Definition.Components[name] + bindings := map[string]any{} + for _, key := range sortedKeys(c.Bindings) { + if p.Binding != "" && p.Binding != key { + continue + } + b := c.Bindings[key] + bindings[key] = map[string]any{ + "module": b.Module, "source": b.Source, "location": p.FileSpec.Sources[b.Source], + "description": b.Description, "args": b.Args, "tools": b.Tools, "options": b.Options, + } + } + components[name] = map[string]any{"description": c.Description, "bindings": bindings} + } + return map[string]any{ + "version": 2, + "profile": p.Name, + "environment": p.Definition.Environment, + "description": p.Definition.Description, + "components": components, + } +} diff --git a/cmd/system/monitor/profile_runtime.go b/cmd/system/monitor/profile_runtime.go new file mode 100644 index 0000000..d068dac --- /dev/null +++ b/cmd/system/monitor/profile_runtime.go @@ -0,0 +1,206 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "context" + "errors" + "strings" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + "github.com/TencentBlueKing/bk-cli/internal/output" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +type ( + profileContextKey struct{} + runtimeContextKey struct{} +) + +func addProfileFlags(cmd *cobra.Command) { + if cmd.Name() == "show" { + output.AddJQFlag(cmd) + } else { + var stage string + var headers []string + systemcmd.AddCommonRequestFlagsWithoutBody(cmd, &stage, &headers) + } + cmd.Flags().String("profile", "", "Named SRE-maintained observability profile (no credentials)") + cmd.Flags().String("component", "", "V2 business component; required when the profile has multiple components") + cmd.Flags().String( + "binding", + "", + "V2 named observability binding; required when this module has multiple candidates", + ) + cmd.Flags().String( + "profile_file", + "", + "Profiles YAML file; defaults to BK_CLI_MONITOR_PROFILE_FILE or config-dir/monitor/profiles.yaml", + ) +} + +func currentProfile(cmd *cobra.Command) *selectedProfile { + if cmd.Context() == nil { + return nil + } + selection, _ := cmd.Context().Value(profileContextKey{}).(*selectedProfile) + return selection +} + +func selectProfile(cmd *cobra.Command, deps systemcmd.BuildDeps) (systemcmd.BuildDeps, error) { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + cmd.SetContext(context.WithValue(ctx, profileContextKey{}, (*selectedProfile)(nil))) + name, _ := cmd.Flags().GetString("profile") + fileFlag, _ := cmd.Flags().GetString("profile_file") + if name == "" { + if cmd.Flags().Changed("profile") || fileFlag != "" || cmd.Flags().Changed("component") || + cmd.Flags().Changed("binding") { + return deps, monitorapi.InputError( + "needs_profile", + "请用 --profile 选择名字;查看可用配置:bk-cli monitor profile list --profile_file FILE。", + ) + } + return deps, nil + } + path, err := profileFilePath(fileFlag) + if err != nil { + return deps, err + } + file, hash, err := loadProfiles(path) + if err != nil { + return deps, err + } + p, ok := file.Profiles[name] + if !ok { + return deps, monitorapi.InputError("profile_not_found", "文件中没有该 Profile;用 profile list 查看名称。") + } + selection := &selectedProfile{ + Name: name, + File: path, + SHA256: hash, + Profile: p, + Applied: []string{}, + Version: file.Version, + Definition: &p, + FileSpec: file, + } + cmd.SetContext(context.WithValue(cmd.Context(), profileContextKey{}, selection)) + if file.Version == 2 { + if err := selectV2Profile(cmd, selection); err != nil { + return deps, err + } + if profileInspection(cmd) { + return deps, nil + } + } else { + component, _ := cmd.Flags().GetString("component") + binding, _ := cmd.Flags().GetString("binding") + if component != "" || binding != "" { + return deps, monitorapi.InputError( + "profile_version_mismatch", + "--component/--binding 需要 version: 2。", + ) + } + } + return bindProfileRuntime(cmd, deps, selection) +} + +func runMonitor( + deps systemcmd.BuildDeps, + fn func(*cobra.Command, *monitorapi.Runtime) error, +) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, _ []string) error { + bound, err := selectProfile(cmd, deps) + if err == nil { + if p := currentProfile( + cmd, + ); p != nil && p.Version == 2 && cmd.Name() == "check" && + cmd.Flags().Lookup("tool") == nil { + return runV2Report(cmd, bound, p, false, false, false) + } + raw, resolveErr := systemcmd.ResolveRuntime(bound) + err = resolveErr + rt := monitorapi.Bind(cmd, raw) + if err == nil { + cmd.SetContext(context.WithValue(cmd.Context(), runtimeContextKey{}, rt.ContextName)) + err = fn(cmd, rt) + } + } + return printMonitorFailure(cmd, err) + } +} + +func profileMetadata(selection *selectedProfile) map[string]any { + if selection.Version == 2 { + meta := map[string]any{ + "version": 2, + "name": selection.Name, + "file": selection.File, + "sha256": selection.SHA256, + "component": selection.Component, + "binding": selection.Binding, + "source": selection.Source, + "applied_fields": selection.Applied, + "ignored_fields": selection.Ignored, + "resolved_parameters": selection.Resolved, + } + if selection.Route != nil { + meta["module"] = selection.Route.Module + meta["context"] = selection.Profile.Context + meta["bk_biz_id"] = selection.Profile.BizID + } + return meta + } + return map[string]any{ + "name": selection.Name, "file": selection.File, "sha256": selection.SHA256, + "context": selection.Profile.Context, "bk_biz_id": selection.Profile.BizID, + "applied_fields": selection.Applied, "resolved_parameters": selection.Resolved, + } +} + +// Preserve data's original shape for scripts and --from. The profile field is an +// opt-in, monitor-local addition, not a change to the shared Envelope contract. +func writeMonitorEnvelope(cmd *cobra.Command, env *output.Envelope) error { + selection := currentProfile(cmd) + if selection == nil { + return output.WriteCommandEnvelope(cmd, env) + } + return output.WriteCommandEnvelopeWithMetadata(cmd, env, map[string]any{"profile": profileMetadata(selection)}) +} + +func printMonitorFailure(cmd *cobra.Command, err error) error { + selection := currentProfile(cmd) + var decision *monitorapi.Decision + if selection != nil && errors.As(err, &decision) { + for i, next := range decision.Next { + if strings.HasPrefix(next, "bk-cli monitor ") { + decision.Next[i] = next + profileSuffix(selection) + } + } + return monitorapi.PrintFailure(cmd, err, map[string]any{"profile": profileMetadata(selection)}) + } + return monitorapi.PrintFailure(cmd, err) +} + +func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } diff --git a/cmd/system/monitor/profile_test.go b/cmd/system/monitor/profile_test.go new file mode 100644 index 0000000..8783f52 --- /dev/null +++ b/cmd/system/monitor/profile_test.go @@ -0,0 +1,503 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + json "github.com/goccy/go-json" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + systemtest "github.com/TencentBlueKing/bk-cli/cmd/system/testutil" + "github.com/TencentBlueKing/bk-cli/internal/config" + "github.com/TencentBlueKing/bk-cli/internal/output" + "github.com/TencentBlueKing/bk-cli/internal/update" +) + +// Synthetic scopes and resources; not a deployment configuration. +const testProfiles = `version: 1 +profiles: + worker: &base + description: worker logs and metrics + context: default + bk_biz_id: "100100" + log: {index_set_id: 101003} + metric: {table_id: example.table} + apm: {app_name: checkout, service_name: api} + ns-a: + <<: *base + log: + scene: k8s + where: {cluster_id: test-cluster, namespace: ns-a} + ns-b: + <<: *base + log: + scene: k8s + where: {cluster_id: test-cluster, namespace: ns-b} +` + +var _ = Describe("SRE profiles", func() { + var dir, file string + BeforeEach(func() { + dir = GinkgoT().TempDir() + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", dir) + GinkgoT().Setenv("BK_CLI_MONITOR_PROFILE_FILE", "") + file = filepath.Join(dir, "profiles.yaml") + Expect(os.WriteFile(file, []byte(testProfiles), 0o600)).To(Succeed()) + }) + + It("supports YAML anchors, named profiles and exact integer identifiers", func() { + value, hash, err := loadProfiles(file) + Expect(err).NotTo(HaveOccurred()) + Expect(hash).To(HaveLen(64)) + Expect(value.Profiles["ns-b"].Metric.TableID).To(Equal("example.table")) + Expect(value.Profiles["ns-b"].Log.Where["namespace"]).To(Equal("ns-b")) + raw := strings.Replace(testProfiles, "101003", "9007199254740993", 1) + Expect(os.WriteFile(file, []byte(raw), 0o600)).To(Succeed()) + value, _, err = loadProfiles(file) + Expect(err).NotTo(HaveOccurred()) + Expect(value.Profiles["worker"].Log.IndexSetID).To(Equal(int64(9007199254740993))) + }) + + DescribeTable( + "fails closed on invalid profiles without echoing parser values", + func(raw string) { + Expect(os.WriteFile(file, []byte(raw), 0o600)).To(Succeed()) + _, _, err := loadProfiles(file) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).NotTo(ContainSubstring("secret-value")) + }, + Entry("unknown version", strings.Replace(testProfiles, "version: 1", "version: 2", 1)), + Entry( + "token", + strings.Replace( + testProfiles, + "context: default", + "context: default\n access_token: secret-value", + 1, + ), + ), + Entry( + "URL instead of context", + strings.Replace(testProfiles, "context: default", "context: https://secret-value", 1), + ), + Entry( + "both log modes", + strings.Replace( + testProfiles, + "{index_set_id: 101003}", + "{index_set_id: 101003, scene: host}", + 1, + ), + ), + Entry( + "where without scene", + strings.Replace( + testProfiles, + "{index_set_id: 101003}", + "{index_set_id: 101003, where: {namespace: ns-a}}", + 1, + ), + ), + Entry( + "conditions without index", + strings.Replace( + testProfiles, + "{index_set_id: 101003}", + "{scene: host, conditions: {key: value}}", + 1, + ), + ), + Entry( + "duplicate key", + strings.Replace(testProfiles, "context: default", "context: default\n context: default", 1), + ), + Entry("multiple YAML documents", testProfiles+"\n---\nversion: 1\n"), + Entry("empty business", strings.Replace(testProfiles, `bk_biz_id: "100100"`, `bk_biz_id: ""`, 1)), + Entry("unsafe name", strings.Replace(testProfiles, "worker: &base", "'bad;name': &base", 1)), + ) + + It("bounds file sizes and accepts negative business spaces", func() { + Expect(os.WriteFile(file, []byte(strings.Repeat("x", maxInputBytes+1)), 0o600)).To(Succeed()) + _, _, err := loadProfiles(file) + Expect(err).To(HaveOccurred()) + Expect( + os.WriteFile(file, []byte(strings.Replace(testProfiles, `"100100"`, `"-51"`, 1)), 0o600), + ).To( + Succeed(), + ) + _, _, err = loadProfiles(file) + Expect(err).NotTo(HaveOccurred()) + }) + + It("lists and shows files with no context or credentials installed", func() { + cmd := newProfileListCmd() + setFlags(cmd, "profile_file", file) + env, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(env["data"].(map[string]any)["profiles"]).To(HaveLen(3)) + cmd = newProfileShowCmd(systemtest.BuildDeps(false)) + setFlags(cmd, "profile", "worker", "profile_file", file) + env, err = runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(env["data"].(map[string]any)["context"]).To(Equal("default")) + Expect(config.ContextExists("default")).To(BeFalse()) + }) + + It("resolves an explicit file before the env file and never selects a default profile", func() { + GinkgoT().Setenv("BK_CLI_MONITOR_PROFILE_FILE", filepath.Join(dir, "missing.yaml")) + cmd := newProfileShowCmd(systemtest.BuildDeps(false)) + setFlags(cmd, "profile", "worker", "profile_file", file) + _, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + cmd = newProfileShowCmd(systemtest.BuildDeps(false)) + _, err = runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("needs_profile")) + }) + + It("fails on missing profiles and context/business mismatches before making requests", func() { + deps := systemtest.BuildDeps(false) + deps.GetContext = func() string { return "other" } + cmd := newToolCmd(toolsByName["get_index_set_fields"], deps) + setFlags(cmd, "profile", "worker", "profile_file", file) + _, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("profile_context_mismatch")) + cmd = toolCommand("get_index_set_fields", false) + setFlags(cmd, "profile", "worker", "profile_file", file, "bk_biz_id", "200200") + _, err = runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("profile_business_mismatch")) + cmd = toolCommand("get_index_set_fields", false) + setFlags(cmd, "profile", "unknown", "profile_file", file) + _, err = runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("profile_not_found")) + }) + + It("fills only accepted tool parameters, preserving flags > body > profile", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("get_index_set_fields", true) + setFlags( + cmd, + "profile", + "worker", + "profile_file", + file, + "body", + `{"index_set_id":111}`, + "index_set_id", + "222", + ) + args := dryRunArgs(cmd) + Expect(args).To(Equal(map[string]any{"bk_biz_id": "100100", "index_set_id": float64(222)})) + cmd = toolCommand("list_time_series_metrics", true) + setFlags(cmd, "profile", "worker", "profile_file", file) + args = dryRunArgs(cmd) + Expect(args).To(Equal(map[string]any{"bk_biz_id": "100100", "table_id": "example.table"})) + cmd = toolCommand("get_index_set_fields", true) + setFlags(cmd, "profile", "worker", "profile_file", file, "body", `{"bk_biz_id":"200200"}`) + _, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("profile_business_mismatch")) + }) + + It("applies scene namespace profiles deterministically and overrides where per key", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("search_logs", true) + setFlags(cmd, "profile", "ns-a", "profile_file", file, "where", "namespace=ns-b", "since", "15m") + args := dryRunArgs(cmd) + Expect(args["target_type"]).To(Equal("scene")) + clauses := args["table_id_conditions"].([]any)[0].([]any) + Expect(clauses).To(HaveLen(3)) + Expect(clauses[2].(map[string]any)["value"]).To(Equal([]any{"ns-b"})) + }) + + DescribeTable("does not silently switch log modes", func(profile, name, key, value string) { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand(name, true) + setFlags(cmd, "profile", profile, "profile_file", file) + if key != "" { + setFlags(cmd, key, value) + } + _, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("profile_log_mode_mismatch")) + }, + Entry("index to scene", "worker", "search_logs", "scene", "host"), + Entry("scene to index", "ns-a", "search_logs", "index_set_id", "101003"), + Entry("scene to different scene", "ns-a", "search_logs", "scene", "host"), + Entry("index-only context tool", "ns-a", "search_index_set_context", "", ""), + ) + + It("keeps response data compatible and makes one direct execute call with provenance", func() { + calls := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + calls = append(calls, facade(r.URL.Path)) + req := readMonitorRequest(r) + Expect( + req, + ).To( + Equal(map[string]any{"bk_biz_id": "100100", "index_set_id": float64(101003)}), + ) + _, _ = w.Write( + []byte( + `{"result":true,"data":{"fields":[]}}`, + ), + ) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := toolCommand("get_index_set_fields", false) + setFlags(cmd, "profile", "worker", "profile_file", file) + env, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(calls).To(Equal([]string{"get_index_set_fields"})) + Expect(env["data"]).To(Equal(map[string]any{"fields": []any{}})) + Expect(env["profile"].(map[string]any)["applied_fields"]).To(Equal([]any{"bk_biz_id", "index_set_id"})) + }) + + It("uses profile resources for permission apply without executing business tools", func() { + calls := []string{} + server := checkServer( + &calls, + `{"result":true,"data":{"authorized":true,"scopes":[{"tool_name":"get_index_set_fields","state":"granted"}]}}`, + ) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := newPermissionApplyCmd(systemtest.BuildDeps(false)) + setFlags(cmd, "profile", "worker", "profile_file", file, "tool", "get_index_set_fields") + env, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(env["data"].(map[string]any)["decision"]).To(Equal("ready")) + Expect(calls).NotTo(ContainElement("execute_tool")) + }) + + It("retains --from anchor mismatch protection with a profile", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + hit := filepath.Join(dir, "hit.json") + Expect( + os.WriteFile( + hit, + []byte( + `{"__index_set_id__":999,"dtEventTimeStamp":"1","serverIp":"192.0.2.10","gseIndex":"1"}`, + ), + 0o600, + ), + ).To( + Succeed(), + ) + cmd := toolCommand("search_index_set_context", true) + setFlags(cmd, "profile", "worker", "profile_file", file, "from", hit) + _, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("anchor_index_mismatch")) + }) + + It("checks configured resources through metadata only and catches an invalid metric table", func() { + calls := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + req := readMonitorRequest(r) + name, _ := req["tool_name"].(string) + op := facade(r.URL.Path) + if name == "" { + name = op + calls = append(calls, op) + } else { + calls = append(calls, op+":"+name) + } + data := map[string]any{} + switch op { + case "lookup_tool": + data["tools"] = []any{ + map[string]any{"name": name, "risk": "query", "requires_confirmation": false}, + } + case "lookup_tool_schema": + data["catalog_version"] = catalogData.Version + data["permission"] = map[string]any{ + "fallback_permission": map[string]any{"resource_arg": "index_set_id"}, + } + case "lookup_permissions": + data["authorized"] = true + data["scopes"] = []any{map[string]any{"tool_name": name, "state": "granted"}} + case "get_index_set_fields", "list_time_series_metrics", "list_apm_services": + Expect( + name, + ).To( + BeElementOf( + "get_index_set_fields", + "list_time_series_metrics", + "list_apm_services", + ), + ) + if name == "list_time_series_metrics" { + _, _ = w.Write([]byte(`{"result":false,"message":"table does not exist"}`)) + return + } + data = map[string]any{} + } + Expect(json.NewEncoder(w).Encode(map[string]any{"result": true, "data": data})).To(Succeed()) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := newProfileCheckCmd(systemtest.BuildDeps(false)) + setFlags(cmd, "profile", "worker", "profile_file", file) + env, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).ExitCode).To(Equal(1)) + encoded, _ := json.Marshal(env) + Expect(string(encoded)).To(ContainSubstring("resource_not_found")) + Expect(calls).To(ContainElement("list_time_series_metrics")) + Expect(calls).NotTo(ContainElement("search_logs")) + }) + + It("reports catalog drift as a warning, not proof that authentication failed", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"result":true,"data":{"catalog_version":"different"}}`)) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + env, err := runProfileCommand(newStatusCmd(systemtest.BuildDeps(false))) + Expect(err).NotTo(HaveOccurred()) + Expect(env["ok"]).To(BeTrue()) + encoded, _ := json.Marshal(env) + Expect(string(encoded)).To(ContainSubstring(`"remote":"different","status":"warn"`)) + }) + + It("keeps offline/dry-run status network-free and leaves active context untouched", func() { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + before, err := os.ReadFile(filepath.Join(dir, "current")) + Expect(err).NotTo(HaveOccurred()) + for _, dry := range []bool{false, true} { + cmd := newStatusCmd(systemtest.BuildDeps(dry)) + setFlags(cmd, "profile", "worker", "profile_file", file, "check_update", "true") + if !dry { + setFlags(cmd, "offline", "true") + } + env, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(env["ok"]).To(BeTrue()) + } + Expect(calls).To(Equal(0)) + after, err := os.ReadFile(filepath.Join(dir, "current")) + Expect(err).NotTo(HaveOccurred()) + Expect(after).To(Equal(before)) + }) + + It("maps APM service_names only when accepted and does not rewrite PromQL", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("list_apm_services", true) + setFlags(cmd, "profile", "worker", "profile_file", file) + args := dryRunArgs(cmd) + Expect(args["service_names"]).To(Equal([]any{"api"})) + Expect(args).NotTo(HaveKey("service_name")) + cmd = toolCommand("execute_range_query", true) + setFlags(cmd, "profile", "worker", "profile_file", file, "promql", "up", "since", "15m") + args = dryRunArgs(cmd) + Expect(args["promql"]).To(Equal("up")) + Expect(args).NotTo(HaveKey("table_id")) + }) + + It("keeps structured conditions scoped to log search and allows explicit object replacement", func() { + raw := strings.Replace( + testProfiles, + "log: {index_set_id: 101003}", + "log: {index_set_id: 101003, conditions: {condition: and, field_list: []}}", + 1, + ) + Expect(os.WriteFile(file, []byte(raw), 0o600)).To(Succeed()) + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("search_logs", true) + setFlags(cmd, "profile", "worker", "profile_file", file, "since", "15m") + args := dryRunArgs(cmd) + Expect(args["conditions"]).To(HaveKeyWithValue("condition", "and")) + cmd = toolCommand("search_logs", true) + setFlags( + cmd, + "profile", + "worker", + "profile_file", + file, + "since", + "15m", + "conditions", + `{"condition":"or","field_list":[]}`, + ) + Expect(dryRunArgs(cmd)["conditions"]).To(HaveKeyWithValue("condition", "or")) + cmd = toolCommand("list_alerts", true) + setFlags(cmd, "profile", "worker", "profile_file", file, "since", "15m") + Expect(dryRunArgs(cmd)).NotTo(HaveKey("conditions")) + }) + + It("reports comparable releases, local builds and unavailable release sources accurately", func() { + Expect( + releaseCheck("0.1.0", &update.ReleaseInfo{Version: "0.2.0"}, nil), + ).To( + HaveKeyWithValue("newer_release", true), + ) + Expect( + releaseCheck("0.2.0", &update.ReleaseInfo{Version: "0.2.0"}, nil), + ).To( + HaveKeyWithValue("status", "pass"), + ) + preview := releaseCheck("observe-preview", &update.ReleaseInfo{Version: "0.2.0"}, nil) + Expect(preview).To(HaveKeyWithValue("decision", "version_not_comparable")) + Expect(preview).NotTo(HaveKey("newer_release")) + failed := releaseCheck("0.2.0", nil, errors.New("secret upstream error")) + Expect(failed).To(HaveKeyWithValue("decision", "update_check_unavailable")) + Expect(failed).NotTo(HaveKey("message")) + }) + + It("reports an actual authenticated API failure rather than credential-file success", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write( + []byte(`{"result":false,"code":403,"message":"access_token expired secret-value"}`), + ) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := newStatusCmd(systemtest.BuildDeps(false)) + env, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).ExitCode).To(Equal(1)) + Expect(env["ok"]).To(BeFalse()) + encoded, _ := json.Marshal(env) + Expect(string(encoded)).To(ContainSubstring("auth_failed")) + Expect(string(encoded)).NotTo(ContainSubstring("secret-value")) + }) +}) + +func runProfileCommand(cmd *cobra.Command) (map[string]any, error) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SetContext(context.Background()) + err := cmd.RunE(cmd, nil) + env := map[string]any{} + if stdout.Len() > 0 { + Expect(json.Unmarshal(stdout.Bytes(), &env)).To(Succeed()) + } + return env, err +} diff --git a/cmd/system/monitor/profile_v2.go b/cmd/system/monitor/profile_v2.go new file mode 100644 index 0000000..05c2c90 --- /dev/null +++ b/cmd/system/monitor/profile_v2.go @@ -0,0 +1,304 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "maps" + "slices" + "sort" + "strings" + + json "github.com/goccy/go-json" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + "github.com/TencentBlueKing/bk-cli/internal/validate" +) + +// V2 separates the observed system/component from the actual evidence source. +// Native tool arguments remain native; this is not a template or workflow engine. +type profileSource struct { + Context string `yaml:"context" json:"context"` + BizID string `yaml:"bk_biz_id" json:"bk_biz_id"` +} + +type profileComponent struct { + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Bindings map[string]profileBinding `yaml:"bindings" json:"bindings"` +} + +type profileBinding struct { + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Module string `yaml:"module" json:"module"` + Source string `yaml:"source" json:"source"` + Args map[string]any `yaml:"args,omitempty" json:"args,omitempty"` + Tools map[string]map[string]any `yaml:"tools,omitempty" json:"tools,omitempty"` + Options *logBindingOptions `yaml:"options,omitempty" json:"options,omitempty"` +} + +type logBindingOptions struct { + Scene string `yaml:"scene,omitempty" json:"scene,omitempty"` + Where map[string]string `yaml:"where,omitempty" json:"where,omitempty"` + Filters map[string]string `yaml:"filters,omitempty" json:"filters,omitempty"` +} + +var bindingResourceArgs = map[string]bool{ + "index_set_id": true, "table_id": true, "result_table_id": true, "app_name": true, + "service_name": true, "service_names": true, "dashboard_uid": true, "collector_config_id": true, +} + +func validateV2(file *profileFile) error { + bad := func(message string) error { return monitorapi.InputError("invalid_profile_v2", message) } + if len(file.Sources) == 0 || len(file.Sources) > 100 { + return bad("version: 2 需要 1–100 个具名 sources。") + } + for name, source := range file.Sources { + if !profileNamePattern.MatchString(name) { + return bad("source 名称不合法。") + } + if validate.ValidateContextName(source.Context) != nil { + return bad("source.context 必须是已有 context 的合法名称,不接受 URL 或凭据。") + } + if err := validateProfile(queryProfile{Context: source.Context, BizID: source.BizID}); err != nil { + return err + } + } + for _, name := range sortedProfileNames(file) { + p := file.Profiles[name] + if !profileNamePattern.MatchString(name) || len(p.Components) == 0 || len(p.Components) > 100 { + return bad("V2 Profile 需要合法名称与 1–100 个 components。") + } + if p.Context != "" || p.BizID != "" || p.Log != nil || p.Metric != nil || p.APM != nil { + return bad("V2 Profile 不混用 V1 的 context/bk_biz_id/log/metric/apm;数据来源放在 sources,资源放在 bindings。") + } + for componentName, component := range p.Components { + if !profileNamePattern.MatchString(componentName) || len(component.Bindings) == 0 || + len(component.Bindings) > 100 { + return bad("component 需要合法名称与 1–100 个 bindings。") + } + for bindingName, binding := range component.Bindings { + if !profileNamePattern.MatchString(bindingName) { + return bad("binding 名称不合法。") + } + if _, ok := file.Sources[binding.Source]; !ok { + return bad("binding 引用了不存在的 source。") + } + if moduleByName(binding.Module).Name == "" { + return bad("binding.module 必须是现有的 monitor 业务模块。") + } + if err := validateBinding(&binding); err != nil { + return err + } + component.Bindings[bindingName] = binding + } + } + } + return nil +} + +func validateBinding(b *profileBinding) error { + bad := func(message string) error { return monitorapi.InputError("invalid_profile_binding", message) } + var err error + b.Args, err = normalizeProfileArgs(b.Args) + if err != nil { + return err + } + for key, value := range b.Args { + if !bindingResourceArgs[key] { + return bad("binding.args 只放公共资源定位字段;查询、条件、id 等放在 tools.<精确工具名> 中。") + } + found := false + for _, item := range catalogData.Tools { + if item.Module != b.Module { + continue + } + for _, p := range item.Params { + if p.Name == key { + found = true + if err := validatePresetValue(p, value); err != nil { + return err + } + } + } + } + if !found { + return bad("binding.args 字段不属于该 module。") + } + } + if err := validateBindingTools(b); err != nil { + return err + } + if b.Options != nil { + if b.Module != "log" { + return bad("options.scene/where/filters 仅适用于 log;其他模块用工具原始参数。") + } + if err := validateLogFilters(b.Options.Filters); err != nil { + return err + } + if b.Options.Scene == "" && len(b.Options.Where) > 0 { + return bad("options.where 需要 options.scene。") + } + for k, v := range b.Options.Where { + if strings.TrimSpace(k) == "" || strings.TrimSpace(v) == "" || k == "scene" { + return bad("options.where 需要非空路由标签和值。") + } + } + } + // Validate mode conflicts for both the base binding and every per-tool override. + for _, name := range append([]string{""}, sortedKeys(b.Tools)...) { + if _, err := bindingLog(*b, bindingArgs(*b, name)); err != nil { + return err + } + } + return nil +} + +func validateBindingTools(b *profileBinding) error { + bad := func(message string) error { return monitorapi.InputError("invalid_profile_binding", message) } + var err error + for name, values := range b.Tools { + item, ok := toolsByName[name] + if !ok || item.Module != b.Module { + return bad("tools 只能引用当前 module 的已审查只读工具。") + } + values, err = normalizeProfileArgs(values) + if err != nil { + return err + } + for key, value := range values { + if key == "bk_biz_id" || key == "start_time" || key == "end_time" || key == "timestamp" || + key == "query_list" { + return bad("业务来自 source;时间和关联批次 query_list 必须在运行时显式提供,不能固定在 Profile。") + } + found := false + for _, p := range item.Params { + if p.Name == key { + found = true + if err := validatePresetValue(p, value); err != nil { + return err + } + } + } + if !found { + return bad("tools 中的参数不属于该精确工具。") + } + } + b.Tools[name] = values + } + return nil +} + +func normalizeProfileArgs(values map[string]any) (map[string]any, error) { + if values == nil { + return map[string]any{}, nil + } + raw, err := json.Marshal(values) + if err != nil { + return nil, monitorapi.InputError("invalid_profile_args", "Profile 参数必须是 JSON 兼容值。") + } + var result map[string]any + if err := decodeJSON(raw, &result); err != nil { + return nil, monitorapi.InputError("invalid_profile_args", "无法解析 Profile 参数。") + } + return result, nil +} + +func validatePresetValue(p param, value any) error { + valid := false + switch p.Type { + case "string": + _, valid = value.(string) + case "integer": + n, ok := value.(json.Number) + if ok { + _, err := n.Int64() + valid = err == nil + } + case "number": + _, valid = value.(json.Number) + case "boolean": + _, valid = value.(bool) + case "array": + items, ok := value.([]any) + valid = ok + if ok && isScalar(p.Items) { + for _, item := range items { + if err := validatePresetValue(param{Name: p.Name, Type: p.Items}, item); err != nil { + return err + } + } + } + case "object": + _, valid = value.(map[string]any) + } + if !valid { + return monitorapi.InputError( + "invalid_profile_args", + "参数 "+p.Name+" 的类型必须是 "+p.Type+";完整 Schema 仍由服务端校验。", + ) + } + if len(p.Enum) > 0 { + text, ok := value.(string) + if ok { + if slices.Contains(p.Enum, text) { + return nil + } + return monitorapi.InputError("invalid_profile_args", "参数 "+p.Name+" 不在允许值中。") + } + } + return nil +} + +func bindingArgs(binding profileBinding, name string) map[string]any { + values := map[string]any{} + maps.Copy(values, binding.Args) + maps.Copy(values, binding.Tools[name]) + return values +} + +func bindingLog(binding profileBinding, values map[string]any) (*logProfile, error) { + if binding.Module != "log" { + return nil, nil + } + l := &logProfile{} + if raw, ok := values["index_set_id"]; ok { + id, valid := exactInteger(raw) + if !valid || id <= 0 { + return nil, monitorapi.InputError("invalid_profile_binding", "index_set_id 必须是正整数。") + } + l.IndexSetID = id + } + if binding.Options != nil { + l.Scene = binding.Options.Scene + l.Where = binding.Options.Where + l.Filters = binding.Options.Filters + } + if l.IndexSetID > 0 && l.Scene != "" { + return nil, monitorapi.InputError("profile_log_mode_mismatch", "同一 binding 不可同时指定索引集和场景。") + } + return l, nil +} + +func sortedKeys[T any](values map[string]T) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/system/monitor/profile_v2_check.go b/cmd/system/monitor/profile_v2_check.go new file mode 100644 index 0000000..638ba78 --- /dev/null +++ b/cmd/system/monitor/profile_v2_check.go @@ -0,0 +1,322 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "context" + "os" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + "github.com/TencentBlueKing/bk-cli/internal/config" + "github.com/TencentBlueKing/bk-cli/internal/output" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" + "github.com/TencentBlueKing/bk-cli/internal/update" +) + +// Only these resource/definition reads may run during profile check. Query presets +// (PromQL, spans, alerts, relation batches, etc.) get permission preflight only. +var profileMetadataTools = map[string]bool{ + "get_index_set_fields": true, "get_scene_log_fields": true, "list_time_series_metrics": true, + "list_time_series_groups": true, "list_apm_services": true, "list_apm_applications": true, + "get_strategy_detail": true, "get_alarm_strategy": true, "get_dashboard_detail_by_uid": true, + "get_dashboard_tree_list": true, "get_log_collector": true, "get_log_index_set": true, + "list_log_collectors": true, "list_index_sets": true, "list_bcs_clusters": true, +} + +func v2ProbeTools(b profileBinding) []string { + if len(b.Tools) > 0 { + return sortedKeys(b.Tools) + } + switch b.Module { + case "log": + if b.Options != nil && b.Options.Scene != "" { + return []string{"get_scene_log_fields"} + } + if b.Args["index_set_id"] != nil { + return []string{"get_index_set_fields"} + } + return []string{"list_index_sets"} + case "metric": + if b.Args["table_id"] != nil { + return []string{"list_time_series_metrics"} + } + return []string{"list_time_series_groups"} + case "apm": + if b.Args["app_name"] != nil { + return []string{"list_apm_services"} + } + return []string{"list_apm_applications"} + case "alert": + return []string{"list_alerts"} + case "alarm": + return []string{"search_alarm_strategies"} + case "event": + return []string{"list_events"} + case "dashboard": + if b.Args["dashboard_uid"] != nil { + return []string{"get_dashboard_detail_by_uid"} + } + return []string{"get_dashboard_tree_list"} + case "relation": + return []string{"find_relations"} + case "collect": + return []string{"list_log_collectors"} + case "extract": + return []string{"list_log_extract_topology"} + default: + return []string{"list_bcs_clusters"} + } +} + +type bindingCheck struct{ Component, Binding, Tool string } + +func v2Checks(p *selectedProfile) ([]bindingCheck, error) { + result := []bindingCheck{} + for _, c := range sortedKeys(p.Definition.Components) { + if p.Component != "" && p.Component != c { + continue + } + for _, b := range sortedKeys(p.Definition.Components[c].Bindings) { + if p.Binding != "" && p.Binding != b { + continue + } + for _, tool := range v2ProbeTools(p.Definition.Components[c].Bindings[b]) { + result = append(result, bindingCheck{c, b, tool}) + } + } + } + if len(result) > permissionQueryLimit { + return nil, monitorapi.InputError( + "profile_check_too_large", + "预检超过 20 个工具;用 --component 或 --binding 缩小范围。", + ) + } + return result, nil +} + +func v2ToolValues(p *selectedProfile, toolName string) (map[string]any, error) { + values := map[string]any{} + return values, profileArgs(p, toolsByName[toolName], values, "", nil) +} + +func checkV2Binding(rt *monitorapi.Runtime, p *selectedProfile, toolName string) (map[string]any, error) { + values, err := v2ToolValues(p, toolName) + if err != nil { + return nil, err + } + result, err := check(rt, p.Profile.BizID, toolName, values) + if err != nil { + return nil, err + } + result["validation"] = "permission_only" + metadataTool := toolName + if p.Route.Module == "metric" && values["table_id"] != nil { + metadataTool = "list_time_series_metrics" + } + if !profileMetadataTools[metadataTool] { + return result, nil + } + copy := *p + if err := activateBinding(©, p.Component, p.Binding, metadataTool); err != nil { + return nil, err + } + metadataArgs, err := v2ToolValues(©, metadataTool) + if err != nil { + return nil, err + } + for key := range bindingResourceArgs { + if value, ok := values[key]; ok && hasParam(toolsByName[metadataTool], key) { + metadataArgs[key] = value + } + } + for _, param := range toolsByName[metadataTool].Params { + if param.Required && metadataArgs[param.Name] == nil { + return result, nil + } + } + reply, err := monitorapi.Execute(rt, metadataTool, metadataArgs) + if err != nil { + return nil, err + } + result["validation"], result["metadata_tool"], result["request_id"] = "metadata", metadataTool, reply.RequestID + return result, nil +} + +func v2SourceRuntime( + cmd *cobra.Command, + deps systemcmd.BuildDeps, + source profileSource, + offline bool, +) (*monitorapi.Runtime, map[string]any, int) { + row := map[string]any{"context": source.Context, "bk_biz_id": source.BizID} + if deps.GetContext != nil && deps.GetContext() != "" && deps.GetContext() != source.Context { + row["status"], row["decision"] = "fail", "profile_context_mismatch" + return nil, row, output.ExitCodeUserError + } + name, cfg, err := config.ResolveContextReadOnly(source.Context) + if err != nil || cfg == nil { + row["status"], row["decision"] = "fail", "config_error" + return nil, row, output.ExitCodeUserError + } + if _, err := os.Stat(config.CredentialsPath(name)); err != nil { + row["status"], row["decision"] = "fail", "auth_required" + return nil, row, output.ExitCodeUserError + } + if offline { + row["status"], row["authentication"] = "pass", "not_checked" + return nil, row, 0 + } + deps.GetContext = func() string { return source.Context } + rt, err := systemcmd.ResolveRuntime(deps) + if err != nil { + failure, code := diagnosticFailure(source.Context, err) + row["failure"] = failure + row["status"] = "fail" + return nil, row, code + } + bound := monitorapi.Bind(cmd, rt) + reply, err := monitorapi.Call(bound, "lookup_tool_schema", map[string]any{"tool_name": "search_spaces"}) + if err != nil { + failure, code := diagnosticFailure(source.Context, err) + row["failure"] = failure + row["status"] = "fail" + return nil, row, code + } + remote, _ := monitorapi.Map(reply.Data)["catalog_version"].(string) + row["status"], row["authentication"], row["request_id"] = "pass", "verified", reply.RequestID + row["catalog_local"], row["catalog_remote"] = catalogData.Version, remote + if remote != catalogData.Version { + row["status"] = "warn" + } + return bound, row, 0 +} + +func runV2Report( + cmd *cobra.Command, + deps systemcmd.BuildDeps, + p *selectedProfile, + status, offline, checkUpdate bool, +) error { + checks, err := v2Checks(p) + if err != nil { + return printMonitorFailure(cmd, err) + } + originalContext := cmd.Context() + defer cmd.SetContext(originalContext) + dry := deps.IsDryRun != nil && deps.IsDryRun() + if dry { + plan := []map[string]any{} + for _, item := range checks { + b := p.Definition.Components[item.Component].Bindings[item.Binding] + plan = append( + plan, + map[string]any{ + "component": item.Component, + "binding": item.Binding, + "tool": item.Tool, + "source": b.Source, + "location": p.FileSpec.Sources[b.Source], + }, + ) + } + return writeMonitorEnvelope( + cmd, + &output.Envelope{ + OK: true, + DryRun: true, + Data: map[string]any{ + "plan": plan, + "network": "not_used", + "meaning": "仅列出预检计划,未验证身份、资源或权限。", + }, + }, + ) + } + rows := []map[string]any{} + sources := map[string]any{} + runtimes := map[string]*monitorapi.Runtime{} + exit := 0 + // Resolve/verify each declared source once per invocation, never persist grants or switch the active context. + for _, item := range checks { + b := p.Definition.Components[item.Component].Bindings[item.Binding] + if _, seen := sources[b.Source]; seen { + continue + } + rt, row, code := v2SourceRuntime(cmd, deps, p.FileSpec.Sources[b.Source], offline) + sources[b.Source] = row + runtimes[b.Source] = rt + if code > exit { + exit = code + } + } + for _, item := range checks { + selected, err := cloneBinding(cmd, p, item.Component, item.Binding, item.Tool) + if err != nil { + return printMonitorFailure(cmd, err) + } + row := map[string]any{ + "component": item.Component, + "binding": item.Binding, + "tool": item.Tool, + "source": selected.Source, + "context": selected.Profile.Context, + "bk_biz_id": selected.Profile.BizID, + } + rt := runtimes[selected.Source] + if rt == nil { + row["status"], row["validation"] = "skipped", "not_checked" + } else { + cmd.SetContext(context.WithValue(cmd.Context(), runtimeContextKey{}, rt.ContextName)) + result, err := checkV2Binding(rt, selected, item.Tool) + if err == nil { + row["status"] = "pass" + row["result"] = result + } else { + failure, code := diagnosticFailure(item.Tool, err) + failure["next"] = profileDiagnosticNext(failure, item.Tool, selected) + row["status"], row["failure"] = "fail", failure + if code > exit { + exit = code + } + } + } + rows = append(rows, row) + } + cmd.SetContext(originalContext) + data := map[string]any{ + "ok": exit == 0, "sources": sources, "bindings": rows, "offline": offline, + "meaning": "每个绑定独立检查来源和权限。permission_only 未执行查询,metadata 不证明所有查询表达式或过滤条件有效。", + } + if status { + version := cmd.Root().Annotations["build_version"] + if version == "" { + version = "dev" + } + data["version"] = version + data["update"] = map[string]any{"status": "skipped"} + if checkUpdate && !offline { + source := &update.GitHubSource{Owner: "TencentBlueKing", Repo: "bk-cli"} + release, err := source.CheckLatest(cmd.Context()) + data["update"] = releaseCheck(version, release, err) + } + } + return writeDiagnostic(cmd, data, exit) +} diff --git a/cmd/system/monitor/profile_v2_test.go b/cmd/system/monitor/profile_v2_test.go new file mode 100644 index 0000000..da1d009 --- /dev/null +++ b/cmd/system/monitor/profile_v2_test.go @@ -0,0 +1,353 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + json "github.com/goccy/go-json" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + systemtest "github.com/TencentBlueKing/bk-cli/cmd/system/testutil" + "github.com/TencentBlueKing/bk-cli/internal/config" + "github.com/TencentBlueKing/bk-cli/internal/output" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +// Synthetic scopes and resources; not a deployment configuration. +const testProfilesV2 = `version: 2 +sources: + logs: {context: default, bk_biz_id: "200200"} + traces: {context: cloud, bk_biz_id: "100100"} +profiles: + monitor-prod: + environment: production + components: + backend: + bindings: + log: + module: log + source: logs + args: {index_set_id: 101001} + trace: &trace + module: apm + source: traces + args: {app_name: app-production} + tools: + search_spans: + filters: [{key: service.name, operator: equal, value: [api]}] + traffic: + module: metric + source: traces + args: {table_id: metrics.api} + tools: + execute_range_query: {promql: "sum(request_qps)"} + latency: + module: metric + source: traces + args: {table_id: metrics.api} + tools: + execute_range_query: {promql: "max(request_latency)"} + policy: + module: alarm + source: logs + tools: + get_alarm_strategy: + conditions: [{key: strategy_id, value: ["123"]}] + worker: + bindings: + log: + module: log + source: logs + args: {index_set_id: 101002} + trace: *trace +` + +func v2Deps(dry bool) systemcmd.BuildDeps { + deps := systemtest.BuildDeps(dry) + deps.GetContext = func() string { return "" } + return deps +} + +var _ = Describe("heterogeneous profiles v2", func() { + var dir, file string + BeforeEach(func() { + dir = GinkgoT().TempDir() + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", dir) + GinkgoT().Setenv("BK_CLI_MONITOR_PROFILE_FILE", "") + file = filepath.Join(dir, "v2.yaml") + Expect(os.WriteFile(file, []byte(testProfilesV2), 0o600)).To(Succeed()) + }) + + It("shows environment/components/bindings without credentials or a selected route", func() { + cmd := newProfileListCmd() + setFlags(cmd, "profile_file", file) + env, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect( + env["data"].(map[string]any)["profiles"].([]any)[0], + ).To( + HaveKeyWithValue("components", []any{"backend", "worker"}), + ) + show := newProfileShowCmd(v2Deps(false)) + setFlags(show, "profile", "monitor-prod", "profile_file", file) + env, err = runProfileCommand(show) + Expect(err).NotTo(HaveOccurred()) + Expect(env["data"].(map[string]any)["components"]).To(HaveLen(2)) + Expect(env["profile"]).NotTo(HaveKey("context")) + Expect(config.ContextExists("default")).To(BeFalse()) + }) + + DescribeTable( + "rejects invalid references and ambiguous native defaults", + func(old, new string) { + Expect( + os.WriteFile(file, []byte(strings.Replace(testProfilesV2, old, new, 1)), 0o600), + ).To( + Succeed(), + ) + _, _, err := loadProfiles(file) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).NotTo(ContainSubstring("SECRET")) + }, + Entry("unknown source", "source: logs", "source: nowhere"), + Entry("cross-module preset", "search_spans:", "list_alerts:"), + Entry("mutation preset", "search_spans:", "create_dashboard:"), + Entry( + "credential in source", + `context: default, bk_biz_id: "200200"`, + `context: default, bk_biz_id: "200200", access_token: SECRET`, + ), + Entry("ambiguous id", `args: {index_set_id: 101001}`, `args: {id: 101001}`), + Entry("wrong native type", `args: {index_set_id: 101001}`, `args: {index_set_id: "101001"}`), + Entry( + "unknown tool arg", + `execute_range_query: {promql: "sum(request_qps)"}`, + `execute_range_query: {shell: SECRET}`, + ), + Entry( + "stale time", + `execute_range_query: {promql: "sum(request_qps)"}`, + `execute_range_query: {start_time: "1"}`, + ), + ) + + DescribeTable("never guesses a component or a named query", func(tool, component, binding, code string) { + cmd := newToolCmd(toolsByName[tool], v2Deps(true)) + setFlags(cmd, "profile", "monitor-prod", "profile_file", file) + if component != "" { + setFlags(cmd, "component", component) + } + if binding != "" { + setFlags(cmd, "binding", binding) + } + _, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal(code)) + }, + Entry("component required", "search_logs", "", "", "needs_component"), + Entry("golden metric required", "execute_range_query", "backend", "", "needs_binding"), + Entry("not mounted on worker", "execute_range_query", "worker", "", "profile_binding_missing"), + Entry("wrong capability", "search_logs", "backend", "trace", "profile_binding_mismatch"), + Entry("missing component", "search_logs", "unknown", "", "component_not_found"), + ) + + It("selects per-command context and business without touching the active context", func() { + requests := make(chan map[string]any, 4) + handler := func(label string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + req := readMonitorRequest(r) + requests <- map[string]any{"server": label, "body": req} + Expect( + json.NewEncoder( + w, + ).Encode( + map[string]any{ + "result": true, + "data": map[string]any{}, + }, + ), + ).To( + Succeed(), + ) + } + } + logs := httptest.NewServer(handler("logs")) + cloud := httptest.NewServer(handler("traces")) + DeferCleanup(logs.Close) + DeferCleanup(cloud.Close) + Expect(systemtest.SetupTestContext(logs.URL)).To(Succeed()) + Expect( + config.CreateContext("cloud", &config.Config{BkAPIURLTmpl: cloud.URL + "/{gateway_name}"}), + ).To( + Succeed(), + ) + cred, err := os.ReadFile(config.CredentialsPath("default")) + Expect(err).NotTo(HaveOccurred()) + Expect(os.WriteFile(config.CredentialsPath("cloud"), cred, 0o600)).To(Succeed()) + for _, tc := range []struct { + tool, component, server, biz string + index float64 + }{{"get_index_set_fields", "backend", "logs", "200200", 101001}, {"get_index_set_fields", "worker", "logs", "200200", 101002}, {"search_spans", "backend", "traces", "100100", 0}} { + cmd := newToolCmd(toolsByName[tc.tool], v2Deps(false)) + setFlags(cmd, "profile", "monitor-prod", "profile_file", file, "component", tc.component) + if tc.tool == "search_spans" { + setFlags(cmd, "since", "15m", "limit", "1") + } + env, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + r := <-requests + Expect(r["server"]).To(Equal(tc.server)) + args := r["body"].(map[string]any) + Expect(args["bk_biz_id"]).To(Equal(tc.biz)) + if tc.index > 0 { + Expect(args["index_set_id"]).To(Equal(tc.index)) + } else { + Expect(args["filters"]).To(HaveLen(1)) + } + Expect(env["profile"]).To(HaveKeyWithValue("component", tc.component)) + } + active, err := config.ActiveContextName() + Expect(err).NotTo(HaveOccurred()) + Expect(active).To(Equal("default")) + }) + + It("uses named query presets but lets explicit arguments override them", func() { + Expect(systemtest.SetupTestContext("https://api.example.com")).To(Succeed()) + Expect( + config.CreateContext( + "cloud", + &config.Config{BkAPIURLTmpl: "https://cloud.example.com/{gateway_name}"}, + ), + ).To( + Succeed(), + ) + cred, err := os.ReadFile(config.CredentialsPath("default")) + Expect(err).NotTo(HaveOccurred()) + Expect(os.WriteFile(config.CredentialsPath("cloud"), cred, 0o600)).To(Succeed()) + cmd := newToolCmd(toolsByName["execute_range_query"], v2Deps(true)) + setFlags( + cmd, + "profile", + "monitor-prod", + "profile_file", + file, + "component", + "backend", + "binding", + "traffic", + "since", + "15m", + ) + args := dryRunArgs(cmd) + Expect(args["promql"]).To(Equal("sum(request_qps)")) + Expect(args).NotTo(HaveKey("table_id")) + cmd = newToolCmd(toolsByName["execute_range_query"], v2Deps(true)) + setFlags( + cmd, + "profile", + "monitor-prod", + "profile_file", + file, + "component", + "backend", + "binding", + "traffic", + "since", + "15m", + "promql", + "up", + ) + Expect(dryRunArgs(cmd)["promql"]).To(Equal("up")) + }) + + It("uses exact policy parameters without applying an ID to unrelated tools", func() { + Expect(systemtest.SetupTestContext("https://api.example.com")).To(Succeed()) + cmd := newToolCmd(toolsByName["get_alarm_strategy"], v2Deps(true)) + setFlags(cmd, "profile", "monitor-prod", "profile_file", file, "component", "backend") + args := dryRunArgs(cmd) + Expect(args["conditions"]).To(HaveLen(1)) + Expect(args["bk_biz_id"]).To(Equal("200200")) + cmd = newToolCmd(toolsByName["search_alarm_strategies"], v2Deps(true)) + setFlags(cmd, "profile", "monitor-prod", "profile_file", file, "component", "backend") + Expect(dryRunArgs(cmd)).NotTo(HaveKey("conditions")) + }) + + It("previews all declared routes without requiring contexts or touching the network", func() { + cmd := newProfileCheckCmd(v2Deps(true)) + setFlags(cmd, "profile", "monitor-prod", "profile_file", file) + env, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred()) + Expect(env["dry_run"]).To(BeTrue()) + Expect(env["data"].(map[string]any)["plan"]).To(HaveLen(7)) + Expect(config.ContextExists("default")).To(BeFalse()) + }) + + It("blocks explicit environment/business conflicts and carries component/binding into hints", func() { + deps := v2Deps(true) + deps.GetContext = func() string { return "cloud" } + cmd := newToolCmd(toolsByName["get_index_set_fields"], deps) + setFlags(cmd, "profile", "monitor-prod", "profile_file", file, "component", "backend") + _, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("profile_context_mismatch")) + cmd = newToolCmd(toolsByName["get_index_set_fields"], v2Deps(true)) + setFlags( + cmd, + "profile", + "monitor-prod", + "profile_file", + file, + "component", + "backend", + "bk_biz_id", + "100100", + ) + _, err = runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("profile_business_mismatch")) + p := &selectedProfile{Version: 2, Name: "monitor-prod", Component: "worker", Binding: "log", File: file} + Expect(profileSuffix(p)).To(ContainSubstring("--component worker --binding log")) + }) + + It("supports a source-only binding for every module without pretending all have fixed resource IDs", func() { + for _, module := range modules { + b := profileBinding{Module: module.Name, Source: "logs"} + Expect(validateBinding(&b)).To(Succeed(), module.Name) + for _, name := range v2ProbeTools(b) { + Expect(toolsByName[name].Module).To(Equal(module.Name)) + } + } + }) + + It("reports missing sources as failed rather than falling back to the active context", func() { + cmd := newStatusCmd(v2Deps(false)) + setFlags(cmd, "profile", "monitor-prod", "profile_file", file, "offline", "true") + stdout := &bytes.Buffer{} + cmd.SetOut(stdout) + env, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("monitor_check_failed")) + Expect(env["ok"]).To(BeFalse()) + Expect(env["data"].(map[string]any)["sources"]).To(HaveLen(2)) + }) +}) diff --git a/cmd/system/monitor/reliability_test.go b/cmd/system/monitor/reliability_test.go new file mode 100644 index 0000000..307eff4 --- /dev/null +++ b/cmd/system/monitor/reliability_test.go @@ -0,0 +1,347 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + json "github.com/goccy/go-json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + systemtest "github.com/TencentBlueKing/bk-cli/cmd/system/testutil" + "github.com/TencentBlueKing/bk-cli/internal/output" +) + +var _ = Describe("monitor reliability boundaries", func() { + var dir string + BeforeEach(func() { + dir = GinkgoT().TempDir() + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", dir) + GinkgoT().Setenv("BK_CLI_MONITOR_PROFILE_FILE", "") + }) + + It("constructs a dry-run request for every catalog tool without any network call", func() { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + for _, item := range catalogData.Tools { + cmd := toolCommand(item.Name, true) + window := hasParam(item, "start_time") && hasParam(item, "end_time") + for _, p := range item.Params { + if !p.Required || (window && (p.Name == "start_time" || p.Name == "end_time")) { + continue + } + value := "sample" + if len(p.Enum) > 0 { + value = p.Enum[0] + } + switch p.Type { + case "integer", "number": + value = "1" + case "boolean": + value = "true" + case "object": + value = "{}" + case "array": + if !isScalar(p.Items) { + value = "[]" + } + } + if p.Name == "bk_biz_id" { + value = "100100" + } + if p.TimeUnit != "" { + value = "now" + } + setFlags(cmd, p.Name, value) + } + if item.Name == "search_logs" { + setFlags(cmd, "index_set_id", "101003") + } + if window { + setFlags(cmd, "since", "15m") + } + env, err := runProfileCommand(cmd) + Expect(err).NotTo(HaveOccurred(), item.Name) + Expect(env["dry_run"]).To(BeTrue(), item.Name) + request := env["request"].(map[string]any) + body := previewArgs(env) + Expect(request["method"]).To(Equal(item.Endpoint.Method)) + Expect(request["url"]).To(HaveSuffix(item.Endpoint.Path)) + Expect(body).NotTo(HaveKey("tool_name")) + Expect(body).NotTo(HaveKey("tool_args")) + for _, p := range item.Params { + if p.Required { + Expect(body).To(HaveKey(p.Name), item.Name) + } + } + } + Expect(calls).To(BeZero()) + }) + + It("preserves the actual denied index in the application hint and excludes query text", func() { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"result":false,"code":403,"detail":{"state":"missing"}}`)) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + profileFile := filepath.Join(dir, "profiles.yaml") + Expect(os.WriteFile(profileFile, []byte(testProfiles), 0o600)).To(Succeed()) + cmd := toolCommand("search_logs", false) + setFlags( + cmd, + "profile", + "worker", + "profile_file", + profileFile, + "index_set_id", + "222", + "query_string", + "DO-NOT-ECHO", + "since", + "15m", + ) + stderr := &bytes.Buffer{} + cmd.SetErr(stderr) + err := cmd.RunE(cmd, nil) + Expect(err.(*output.CLIError).Code).To(Equal("permission_missing")) + var env map[string]any + Expect(json.Unmarshal(stderr.Bytes(), &env)).To(Succeed()) + next := env["data"].(map[string]any)["next"].([]any)[0].(string) + Expect(next).To(ContainSubstring(`"index_set_id":222`)) + Expect(next).To(ContainSubstring("--context default")) + Expect(next).To(ContainSubstring("--profile worker")) + Expect(next).NotTo(ContainSubstring("DO-NOT-ECHO")) + }) + + It("returns executable scoped hints for permission lists and metadata errors", func() { + p := &selectedProfile{ + Name: "worker", + File: "profiles.yaml", + Profile: queryProfile{Context: "default", BizID: "100100"}, + } + row := permissionRow("get_index_set_fields", "requires_resource", "100100", "default", p) + Expect(row["next"]).NotTo(ContainSubstring("")) + Expect(row["next"]).To(ContainSubstring("--tool get_index_set_fields")) + for _, code := range []string{"resource_blocked", "resource_not_found", "retry_later"} { + next := profileDiagnosticNext(map[string]any{"decision": code}, "list_time_series_metrics", p) + Expect(strings.Join(next, " ")).NotTo(ContainSubstring("permission apply")) + Expect(strings.Join(next, " ")).NotTo(ContainSubstring("permission check")) + } + decision := monitorapi.InputError("permission_missing", "denied") + err := preservePermissionTarget( + decision, + "default", + "get_index_set_fields", + "100100", + map[string]any{"index_set_id": 101003}, + ) + Expect(err).To(Equal(decision)) + Expect(decision.Next[0]).To(ContainSubstring(`"index_set_id":101003`)) + }) + + It("accepts exact string indices and saved Profile metadata for the same scope", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + file := filepath.Join(dir, "anchor.json") + Expect( + os.WriteFile( + file, + []byte( + `{"ok":true,"profile":{"context":"default","bk_biz_id":"100100","resolved_parameters":{"index_set_id":"101003"}},"data":{"list":[{"dtEventTimeStamp":"1","serverIp":"ip","gseIndex":9007199254740993}]}}`, + ), + 0o600, + ), + ).To( + Succeed(), + ) + cmd := toolCommand("search_index_set_context", true) + setFlags(cmd, "bk_biz_id", "100100", "from", file) + args := dryRunArgs(cmd) + Expect(args["index_set_id"]).To(BeNumerically("==", 101003)) + Expect(args["gseIndex"]).To(Equal("9007199254740993")) + }) + + DescribeTable( + "checks each relation item instead of trusting the outer success", + func(payload, decision string) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write( + []byte( + `{"result":true,"data":` + payload + `}`, + ), + ) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := toolCommand("find_relations", false) + setFlags(cmd, "bk_biz_id", "100100", "query_list", `[{"timestamp":1,"source_info":{}}]`) + stderr := &bytes.Buffer{} + cmd.SetErr(stderr) + _, err := systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + if decision == "" { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err.(*output.CLIError).Code).To(Equal(decision)) + } + Expect(stderr.String()).NotTo(ContainSubstring("SECRET upstream")) + }, + Entry("successful but no matching target", `{"data":[{"code":200,"target_list":[]}]}`, ""), + Entry( + "one failed item", + `{"data":[{"code":400,"message":"SECRET upstream"}]}`, + "relation_query_failed", + ), + Entry("missing item", `{"data":[]}`, "partial_result"), + Entry("missing per-item success code", `{"data":[{}]}`, "relation_query_failed"), + Entry( + "missing relation storage route", + `{"data":[{"code":400,"message":"binding lookup failed for space=SECRET upstream: no usable SurrealDB result table route found"}]}`, + "relation_route_unavailable", + ), + ) + + It("rejects trailing JSON instead of silently using only the first object", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("list_alerts", true) + setFlags(cmd, "body", `{"bk_biz_id":"100100"} {"bk_biz_id":"200200"}`, "since", "15m") + _, err := runProfileCommand(cmd) + Expect(err).To(HaveOccurred()) + Expect(err.(*output.CLIError).Code).To(Equal("invalid_body")) + }) + + It("bounds inline bodies as well as file and stdin bodies", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("list_alerts", true) + setFlags( + cmd, + "body", + `{"bk_biz_id":"100100","query_string":"`+strings.Repeat("x", maxInputBytes)+`"}`, + "since", + "15m", + ) + _, err := runProfileCommand(cmd) + Expect(err).To(HaveOccurred()) + Expect(err.(*output.CLIError).Code).To(Equal("invalid_body")) + }) + + It("preserves large integers in structured named flags", func() { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + cmd := toolCommand("list_alerts", true) + setFlags( + cmd, + "bk_biz_id", + "100100", + "conditions", + `[{"key":"id","value":[9007199254740993]}]`, + "since", + "15m", + ) + stdout, err := systemtest.CaptureCommandStdout(func() error { return cmd.RunE(cmd, nil) }) + Expect(err).NotTo(HaveOccurred()) + Expect(stdout).To(ContainSubstring("9007199254740993")) + }) + + DescribeTable( + "rejects unusable saved anchors", + func(raw, code string) { + Expect(systemtest.SetupTestContext("https://bkapi.example.com/api")).To(Succeed()) + file := filepath.Join(dir, "anchor.json") + Expect(os.WriteFile(file, []byte(raw), 0o600)).To(Succeed()) + cmd := toolCommand("search_index_set_context", true) + setFlags(cmd, "bk_biz_id", "100100", "index_set_id", "101003", "from", file) + _, err := runProfileCommand(cmd) + Expect(err).To(HaveOccurred()) + Expect(err.(*output.CLIError).Code).To(Equal(code)) + }, + Entry( + "nested mock", + `{"ok":true,"data":{"list":[{"__demo_mock__":true,"__index_set_id__":101003,"dtEventTimeStamp":"1","serverIp":"ip","gseIndex":"2"}]}}`, + "mock_record", + ), + Entry( + "failed envelope", + `{"ok":false,"data":{"list":[{"__index_set_id__":101003,"dtEventTimeStamp":"1","serverIp":"ip","gseIndex":"2"}]}}`, + "invalid_from", + ), + Entry("trailing data", `{"dtEventTimeStamp":"1","serverIp":"ip","gseIndex":"2"} {}`, "invalid_from"), + Entry( + "invalid index must not be ignored", + `{"__index_set_id__":"bad","dtEventTimeStamp":"1","serverIp":"ip","gseIndex":"2"}`, + "invalid_anchor_index", + ), + Entry( + "saved in another context", + `{"ok":true,"profile":{"context":"other","bk_biz_id":"100100"},"data":{"list":[{"__index_set_id__":101003,"dtEventTimeStamp":"1","serverIp":"ip","gseIndex":"2"}]}}`, + "anchor_context_mismatch", + ), + Entry( + "saved from another business", + `{"ok":true,"profile":{"context":"default","bk_biz_id":"200200"},"data":{"list":[{"__index_set_id__":101003,"dtEventTimeStamp":"1","serverIp":"ip","gseIndex":"2"}]}}`, + "anchor_business_mismatch", + ), + ) + + DescribeTable( + "never treats a mismatched or inconsistent permission reply as ready", + func(permission string) { + calls := []string{} + server := checkServer(&calls, permission) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := newPermissionCheckCmd(systemtest.BuildDeps(false)) + setFlags( + cmd, + "bk_biz_id", + "100100", + "tool", + "get_index_set_fields", + "body", + `{"index_set_id":101003}`, + ) + stderr := &bytes.Buffer{} + cmd.SetErr(stderr) + err := cmd.RunE(cmd, nil) + Expect(err).To(HaveOccurred()) + Expect(err.(*output.CLIError).Code).To(Equal("permission_response_invalid")) + Expect(calls).NotTo(ContainElement("execute_tool")) + }, + Entry("empty scopes", `{"result":true,"data":{"authorized":true,"scopes":[]}}`), + Entry( + "wrong tool", + `{"result":true,"data":{"authorized":true,"scopes":[{"tool_name":"list_alerts","state":"granted"}]}}`, + ), + Entry( + "denied scope but granted summary", + `{"result":true,"data":{"authorized":true,"scopes":[{"tool_name":"get_index_set_fields","state":"missing"}]}}`, + ), + Entry( + "duplicate scopes", + `{"result":true,"data":{"authorized":true,"scopes":[{"tool_name":"get_index_set_fields","state":"granted"},{"tool_name":"get_index_set_fields","state":"granted"}]}}`, + ), + ) +}) diff --git a/cmd/system/monitor/review_contract_test.go b/cmd/system/monitor/review_contract_test.go new file mode 100644 index 0000000..78b6ed0 --- /dev/null +++ b/cmd/system/monitor/review_contract_test.go @@ -0,0 +1,277 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + json "github.com/goccy/go-json" + + systemtest "github.com/TencentBlueKing/bk-cli/cmd/system/testutil" +) + +var _ = Describe("reviewed Monitor request contracts", func() { + var dir string + BeforeEach(func() { + dir = GinkgoT().TempDir() + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", dir) + GinkgoT().Setenv("BK_CLI_MONITOR_PROFILE_FILE", "") + }) + + It("sends a log command directly with invocation stage, headers and tenant", func() { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + calls.Add(1) + Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.URL.Path).To(Equal("/bk-monitor/testing/mcp/search_logs/")) + Expect(r.Header.Get("X-Example")).To(Equal("probe")) + Expect(r.Header.Get("X-Bkapi-Authorization")).NotTo(BeEmpty()) + Expect(r.Header.Get("X-Bk-Tenant-Id")).To(Equal("tenant-test")) + args := readMonitorRequest(r) + Expect(args).To(HaveKeyWithValue("bk_biz_id", "100100")) + Expect(args).NotTo(HaveKey("tool_name")) + Expect(args).NotTo(HaveKey("tool_args")) + _, err := io.WriteString(w, `{"result":true,"data":{"list":[]}}`) + Expect(err).NotTo(HaveOccurred()) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + cmd := toolCommand("search_logs", false) + setFlags( + cmd, + "bk_biz_id", + "100100", + "index_set_id", + "101001", + "since", + "15m", + "stage", + "testing", + "header", + "X-Example:probe", + "header", + "X-Bk-Tenant-Id:tenant-test", + "jq", + ".data.list", + ) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + Expect(cmd.RunE(cmd, nil)).To(Succeed()) + Expect(stdout.String()).To(Equal("[]\n")) + Expect(calls.Load()).To(Equal(int32(1))) + }) + + DescribeTable( + "projects the complete final envelope including Profile provenance", + func(dry bool, expr, expected string) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + calls.Add(1) + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/bk-monitor/prod/mcp/get_index_set_fields/")) + Expect(r.URL.Query().Get("index_set_id")).To(Equal("101003")) + _, err := io.WriteString(w, `{"result":true,"data":{"fields":[]}}`) + Expect(err).NotTo(HaveOccurred()) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + file := filepath.Join(dir, "profiles.yaml") + Expect(os.WriteFile(file, []byte(testProfiles), 0o600)).To(Succeed()) + cmd := toolCommand("get_index_set_fields", dry) + setFlags(cmd, "profile", "worker", "profile_file", file, "jq", expr) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + Expect(cmd.RunE(cmd, nil)).To(Succeed()) + Expect(stdout.String()).To(Equal(expected)) + if dry { + Expect(calls.Load()).To(BeZero()) + } else { + Expect(calls.Load()).To(Equal(int32(1))) + } + }, + Entry( + "native result", + false, + `{profile:.profile.name,fields:.data.fields}`, + "{\"fields\":[],\"profile\":\"worker\"}\n", + ), + Entry( + "redacted preview", + true, + `{profile:.profile.name,index:.request.params.index_set_id,auth:.request.headers["X-Bkapi-Authorization"]}`, + "{\"auth\":\"{...redacted...}\",\"index\":101003,\"profile\":\"worker\"}\n", + ), + ) + + It("retains Profile provenance and the completed result when jq execution fails", func() { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _, _ = io.WriteString(w, `{"result":true,"data":{"fields":[]}}`) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + file := filepath.Join(dir, "profiles.yaml") + Expect(os.WriteFile(file, []byte(testProfiles), 0o600)).To(Succeed()) + cmd := toolCommand("get_index_set_fields", false) + setFlags(cmd, "profile", "worker", "profile_file", file, "jq", `.profile.name | .missing[]`) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + Expect(cmd.RunE(cmd, nil)).NotTo(Succeed()) + Expect(calls.Load()).To(Equal(int32(1))) + var env map[string]any + Expect(json.Unmarshal(stdout.Bytes(), &env)).To(Succeed()) + Expect(env["profile"].(map[string]any)["name"]).To(Equal("worker")) + Expect(env["data"]).To(HaveKey("fields")) + Expect(stderr.String()).To(ContainSubstring("jq_execution_error")) + Expect(stderr.String()).To(ContainSubstring("request has already completed")) + }) + + It("rejects invalid jq before context, body input or network access", func() { + cmd := toolCommand("search_logs", false) + cmd.SetArgs([]string{"--body", "-", "--jq", ".["}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + Expect(cmd.Execute()).To(MatchError(ContainSubstring("--jq"))) + }) + + DescribeTable( + "checks the resource resolved for the original tool, with options on every intermediate request", + func(table string, valid bool) { + tables := []string{} + sqlCalls := 0 + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + calls++ + Expect(r.URL.Path).To(HavePrefix("/bk-monitor/testing/mcp/")) + Expect(r.Header.Get("X-Example")).To(Equal("preflight")) + op := facade(r.URL.Path) + args := readMonitorRequest(r) + data := map[string]any{} + switch op { + case "lookup_tool": + data["tools"] = []any{ + map[string]any{ + "name": args["tool_name"], + "risk": "query", + "requires_confirmation": false, + }, + } + case "lookup_tool_schema": + data["catalog_version"] = catalogData.Version + data["permission"] = map[string]any{ + "fallback_permission": map[string]any{ + "resource_arg": "bk_biz_id", + "target_arg": "table_id", + }, + } + case "lookup_permissions": + data["authorized"] = true + data["scopes"] = []any{ + map[string]any{"tool_name": args["tool_name"], "state": "granted"}, + } + case "list_time_series_metrics": + Expect(r.Method).To(Equal(http.MethodGet)) + tables = append(tables, r.URL.Query().Get("table_id")) + if !valid { + _, _ = io.WriteString( + w, + `{"result":false,"message":"table does not exist"}`, + ) + return + } + data["fields"] = []any{} + case "execute_sql_query": + sqlCalls++ + Fail("profile check must not execute SQL") + default: + Fail("unexpected preflight API: " + op) + } + Expect( + json.NewEncoder(w).Encode(map[string]any{"result": true, "data": data}), + ).To( + Succeed(), + ) + })) + DeferCleanup(server.Close) + Expect(systemtest.SetupTestContext(server.URL)).To(Succeed()) + profile := `version: 2 +sources: {sample-source: {context: default, bk_biz_id: "100100"}} +profiles: + sample: + components: + api: + bindings: + metrics: + module: metric + source: sample-source + args: {table_id: existing.table} + tools: + execute_sql_query: {table_id: TABLE, sql: 'SELECT 1'} +` + file := filepath.Join(dir, "profiles.yaml") + Expect( + os.WriteFile(file, []byte(strings.ReplaceAll(profile, "TABLE", table)), 0o600), + ).To( + Succeed(), + ) + cmd := newProfileCheckCmd(systemtest.BuildDeps(false)) + setFlags( + cmd, + "profile", + "sample", + "profile_file", + file, + "stage", + "testing", + "header", + "X-Example:preflight", + ) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + err := cmd.RunE(cmd, nil) + if valid { + Expect(err).NotTo(HaveOccurred()) + Expect(stdout.String()).To(ContainSubstring(`"validation": "metadata"`)) + } else { + Expect(err).To(HaveOccurred()) + Expect(stdout.String()).To(ContainSubstring("resource_not_found")) + } + Expect(tables).To(Equal([]string{table})) + Expect(sqlCalls).To(BeZero()) + Expect(calls).To(BeNumerically(">", 3)) + }, + Entry("overridden missing table", "missing.table", false), + Entry("overridden visible table", "override.table", true), + ) +}) diff --git a/cmd/system/monitor/spec.go b/cmd/system/monitor/spec.go new file mode 100644 index 0000000..73b71dc --- /dev/null +++ b/cmd/system/monitor/spec.go @@ -0,0 +1,118 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +// Package monitor implements user-mode BlueKing Monitor commands backed by individual HTTP APIs. +package monitor + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +var catalogData, toolsByName, catalogErr = loadCatalog() + +func init() { + if catalogErr == nil { + monitorapi.SetCatalog(supportedTools(), commandName, nextCommands) + routes := map[string]monitorapi.Endpoint{} + for _, item := range catalogData.Tools { + routes[item.Name] = item.Endpoint + } + monitorapi.SetEndpoints(routes) + } +} + +// NewSystemSpec returns the monitor system spec grouped by observability module. +func NewSystemSpec() systemcmd.SystemSpec { + if catalogErr != nil { + return systemcmd.SystemSpec{ + Name: "monitor", Description: "BlueKing Monitor user-mode observability commands", + RegisterGoActions: func(*cobra.Command, systemcmd.BuildDeps) error { return catalogErr }, + } + } + subsystems := []systemcmd.SystemSpec{newToolsSpec(toolsByName), newPermissionSpec(), newProfileSpec()} + for _, item := range modules { + current := item + subsystems = append(subsystems, systemcmd.SystemSpec{ + Name: current.Name, + Description: current.Description, + RegisterGoActions: func(parent *cobra.Command, deps systemcmd.BuildDeps) error { + parent.Long = current.Description + ".\n\n" + modulePermissionHelp(current.Name) + for _, item := range catalogData.Tools { + if item.Module == current.Name { + parent.AddCommand(newToolCmd(item, deps)) + } + } + return nil + }, + }) + } + return systemcmd.SystemSpec{ + Name: "monitor", + Description: "BlueKing Monitor user-mode observability commands", + RegisterGoActions: func(parent *cobra.Command, deps systemcmd.BuildDeps) error { + parent.AddCommand(newStatusCmd(deps)) + return nil + }, + Subsystems: subsystems, + } +} + +func supportedTools() []string { + names := make([]string, 0, len(catalogData.Tools)) + for _, item := range catalogData.Tools { + names = append(names, item.Name) + } + return names +} + +func commandName(name string) string { + item, ok := toolsByName[name] + if !ok { + return "" + } + return fmt.Sprintf("bk-cli monitor %s %s", item.Module, item.Name) +} + +func nextCommands(name, biz string) []string { + item, ok := toolsByName[name] + if !ok { + return nil + } + var commands []string + for _, previous := range item.Prerequisites { + if command := commandName(previous); command != "" { + if biz != "" && biz != "" && hasParam(toolsByName[previous], "bk_biz_id") { + command += " --bk_biz_id " + biz + } + commands = append(commands, command) + } + } + return commands +} + +func printReply(cmd *cobra.Command, reply *monitorapi.Reply, err error) error { + if err != nil { + return err + } + return writeMonitorEnvelope(cmd, reply.Envelope(reply.Data)) +} diff --git a/cmd/system/monitor/status.go b/cmd/system/monitor/status.go new file mode 100644 index 0000000..9aa2171 --- /dev/null +++ b/cmd/system/monitor/status.go @@ -0,0 +1,208 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "os" + "strings" + + "golang.org/x/mod/semver" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + "github.com/TencentBlueKing/bk-cli/internal/config" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" + "github.com/TencentBlueKing/bk-cli/internal/update" +) + +func newStatusCmd(deps systemcmd.BuildDeps) *cobra.Command { + var offline, checkUpdate bool + cmd := &cobra.Command{ + Use: "status", + Short: "Check local setup, authenticated Monitor API, catalog version and optional profile", + Long: `Check local context and credential-file presence, then make a small authenticated +Monitor schema request. With --profile, also preflight its configured resources. +No log, alert or metric payload is read. --offline and --dry-run never use the network. +--check_update optionally queries the official GitHub release source; it never installs anything.`, + Example: " bk-cli monitor status --profile game-prod\n" + + " bk-cli monitor status --offline\n bk-cli monitor status --check_update", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + bound, err := selectProfile(cmd, deps) + if err != nil { + return printMonitorFailure(cmd, err) + } + if p := currentProfile(cmd); p != nil && p.Version == 2 { + return runV2Report(cmd, bound, p, true, offline, checkUpdate) + } + dry := bound.IsDryRun != nil && bound.IsDryRun() + version := cmd.Root().Annotations["build_version"] + if version == "" { + version = "dev" + } + checks := []map[string]any{{"name": "cli_version", "status": "info", "version": version}} + exit := 0 + fail := func(name string, err error) { + row, code := diagnosticFailure(name, err) + checks = append(checks, row) + if code > exit { + exit = code + } + } + contextName := "" + if bound.GetContext != nil { + contextName = bound.GetContext() + } + selected, cfg, err := config.ResolveContextReadOnly(contextName) + if err != nil || cfg == nil { + fail( + "context", + monitorapi.InputError( + "config_error", + "context 不存在或不可读;先运行 bk-cli context list。", + ), + ) + } else { + checks = append( + checks, + map[string]any{"name": "context", "status": "pass", "context": selected}, + ) + if _, err := os.Stat(config.CredentialsPath(selected)); err != nil { + fail( + "credential_file", + monitorapi.InputError( + "auth_required", + "没有可读凭据文件;请在该 context 下运行 bk-cli auth login。", + ), + ) + } else { + checks = append( + checks, + map[string]any{ + "name": "credential_file", + "status": "pass", + "meaning": "仅证明文件存在,不证明 Token 有效。", + }, + ) + } + } + if !offline && !dry && exit == 0 { + // Explicit read-only resolution above avoids changing the global active context. + bound.GetContext = func() string { return selected } + rt, err := systemcmd.ResolveRuntime(bound) + if err != nil { + fail("runtime", err) + } else { + rt := monitorapi.Bind(cmd, rt) + reply, err := monitorapi.Call( + rt, + "lookup_tool_schema", + map[string]any{"tool_name": "search_spaces"}, + ) + if err != nil { + fail("authenticated_api", err) + } else { + remote, _ := monitorapi.Map(reply.Data)["catalog_version"].(string) + checks = append( + checks, + map[string]any{ + "name": "authenticated_api", + "status": "pass", + "request_id": reply.RequestID, + }, + ) + state := "pass" + if remote == "" || remote != catalogData.Version { + state = "warn" + } + checks = append(checks, map[string]any{ + "name": "catalog", + "status": state, + "local": catalogData.Version, + "remote": remote, + "meaning": "目录版本不一致仅提示需复核快照,不证明工具不兼容。", + }) + if p := currentProfile(cmd); p != nil { + rows, code := checkProfile(rt, p) + checks = append(checks, rows...) + if code > exit { + exit = code + } + } + } + } + } else { + checks = append( + checks, + map[string]any{ + "name": "authenticated_api", + "status": "skipped", + "meaning": "离线、dry-run 或本地检查失败;未验证鉴权和接口。", + }, + ) + } + updateCheck := map[string]any{ + "name": "update", + "status": "skipped", + "next": "bk-cli monitor status --check_update", + } + if checkUpdate && !offline && !dry { + source := &update.GitHubSource{Owner: "TencentBlueKing", Repo: "bk-cli"} + release, err := source.CheckLatest(cmd.Context()) + updateCheck = releaseCheck(version, release, err) + } + checks = append(checks, updateCheck) + return writeDiagnostic(cmd, map[string]any{ + "ok": exit == 0, "offline": offline, "dry_run": dry, "checks": checks, + "meaning": "本地检查通过不等于有业务权限;离线或 skipped 项尚未验证。", + }, exit) + }, + } + addProfileFlags(cmd) + cmd.Flags().BoolVar(&offline, "offline", false, "Only inspect local state; no network or credential decryption") + cmd.Flags().BoolVar( + &checkUpdate, + "check_update", + false, + "Check official GitHub release version without downloading or installing", + ) + return cmd +} + +func releaseCheck(version string, release *update.ReleaseInfo, err error) map[string]any { + if err != nil || release == nil { + return map[string]any{"name": "update", "status": "warn", "decision": "update_check_unavailable"} + } + row := map[string]any{ + "name": "update", "status": "pass", + "current_version": version, "latest_version": release.Version, + } + if !semver.IsValid("v"+strings.TrimPrefix(version, "v")) || + !semver.IsValid("v"+strings.TrimPrefix(release.Version, "v")) { + row["status"], row["decision"] = "warn", "version_not_comparable" + return row + } + newer := update.IsNewer(version, release.Version) + row["newer_release"] = newer + if newer { + row["status"], row["decision"] = "warn", "newer_release_available" + } + return row +} diff --git a/cmd/system/monitor/time_input.go b/cmd/system/monitor/time_input.go new file mode 100644 index 0000000..3e465b1 --- /dev/null +++ b/cmd/system/monitor/time_input.go @@ -0,0 +1,292 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "strconv" + "strings" + "time" + _ "time/tzdata" // Keep IANA zones available in the standalone binary. + + json "github.com/goccy/go-json" + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" +) + +const defaultTimeZone = "Asia/Shanghai" + +var wallTimeLayouts = []string{ + "2006-01-02 15:04:05.999999999", "2006-01-02 15:04", + "2006-01-02T15:04:05.999999999", "2006-01-02T15:04", "2006-01-02", +} + +func hasTimeInput(item tool) bool { + for _, p := range item.Params { + if p.TimeUnit != "" { + return true + } + } + return false +} + +// applyTimeInputs normalizes only catalog-declared top-level time parameters. +// Nested query_list times and exact --from anchor fields are not interpreted here. +func applyTimeInputs(cmd *cobra.Command, item tool, args map[string]any, since string) error { + if !hasTimeInput(item) { + return nil + } + zone, _ := cmd.Flags().GetString("timezone") + if zone == "" || zone == "Local" { + return monitorapi.InputError( + "invalid_timezone", + "--timezone 使用明确的 IANA 时区(如 Asia/Shanghai)或 UTC,不使用 Local。", + ) + } + location, err := time.LoadLocation(zone) + if err != nil { + return monitorapi.InputError("invalid_timezone", "无法识别 --timezone;请使用 Asia/Shanghai、UTC 等 IANA 时区。") + } + reference := now() // One clock sample for both endpoints, including calendar keywords. + parsed := map[string]time.Time{} + if since != "" || cmd.Flags().Changed(sinceFlag) { + if _, exists := args["start_time"]; exists { + return timeConflict() + } + if _, exists := args["end_time"]; exists { + return timeConflict() + } + start, end, err := relativeWindow(item, since, reference) + if err != nil { + return err + } + parsed["start_time"], parsed["end_time"] = start, end + } else { + for _, p := range item.Params { + if p.TimeUnit == "" || args[p.Name] == nil { + continue + } + value, err := parseTimeValue(args[p.Name], p.TimeUnit, location, reference) + if err != nil { + return err + } + parsed[p.Name] = value + } + } + if start, ok := parsed["start_time"]; ok { + if end, exists := parsed["end_time"]; exists { + if !start.Before(end) { + return monitorapi.InputError( + "invalid_time_range", + "start_time 必须早于 end_time;不会交换或扩展时间边界。", + ) + } + if item.Name == "search_logs" && end.Sub(start) > 24*time.Hour { + return monitorapi.InputError( + "invalid_time_range", + "日志检索单次窗口不能超过 24h;请显式拆分,CLI 不截断或自动翻页。", + ) + } + } + } + for _, p := range item.Params { + if value, exists := parsed[p.Name]; exists { + normalized, err := encodedTime(p, value) + if err != nil { + return err + } + args[p.Name] = normalized + } + } + return nil +} + +func timeConflict() error { + return monitorapi.InputError("conflicting_time", "--since 不能与 start_time/end_time 同时使用(命名参数和 --body 都算)。") +} + +func timeResolution(unit string) time.Duration { + if unit == "ms" { + return time.Millisecond + } + return time.Second +} + +func relativeWindow(item tool, since string, reference time.Time) (time.Time, time.Time, error) { + duration, err := time.ParseDuration(since) + if err != nil || duration <= 0 || duration > 24*time.Hour || duration%timeResolution(timeUnit(item)) != 0 { + return time.Time{}, time.Time{}, monitorapi.InputError( + "invalid_since", + "--since 使用 15m、1h、1h30m 等正时长,不超过 24h,且须满足工具的整秒/整毫秒精度。", + ) + } + end := reference.Truncate(timeResolution(timeUnit(item))) + return end.Add(-duration), end, nil +} + +func parseTimeValue(value any, unit string, location *time.Location, reference time.Time) (time.Time, error) { + var raw string + switch v := value.(type) { + case string: + raw = strings.TrimSpace(v) + case json.Number: + raw = v.String() + case int64: + raw = strconv.FormatInt(v, 10) + case int: + raw = strconv.Itoa(v) + default: + return time.Time{}, invalidTime() + } + lower := strings.ToLower(raw) + if lower == "now" || lower == "现在" { + return reference.Truncate(timeResolution(unit)), nil + } + if replaced, ok := calendarTime(lower, location, reference); ok { + raw = replaced + } + if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { + return checkedTime(t, unit) + } + inputUnit := unit + numeric := raw + switch { + case strings.HasSuffix(raw, "ms"): + inputUnit, numeric = "ms", strings.TrimSuffix(raw, "ms") + case strings.HasSuffix(raw, "s"): + inputUnit, numeric = "s", strings.TrimSuffix(raw, "s") + } + if n, err := strconv.ParseInt(numeric, 10, 64); err == nil { + // Bound before unit conversion; never infer s/ms from digit count or overflow. + minimum, maximum := int64(-62135596800), int64(253402300799) + if inputUnit == "ms" { + minimum *= 1000 + maximum = maximum*1000 + 999 + } + if n < minimum || n > maximum { + return time.Time{}, monitorapi.InputError( + "invalid_time", + "时间戳超出可表示日期范围;检查该工具的秒/毫秒单位,或显式使用 s/ms 后缀。", + ) + } + t := time.Unix(n, 0) + if inputUnit == "ms" { + t = time.UnixMilli(n) + } + return checkedTime(t, unit) + } + for _, layout := range wallTimeLayouts { + wall, err := time.Parse(layout, raw) + if err != nil { + continue + } + t := time.Date( + wall.Year(), + wall.Month(), + wall.Day(), + wall.Hour(), + wall.Minute(), + wall.Second(), + wall.Nanosecond(), + location, + ) + if !sameWallTime(t, wall) { + return time.Time{}, monitorapi.InputError( + "nonexistent_local_time", + "该当地时刻因时区切换而不存在;请提供明确的 RFC3339 时间。", + ) + } + if ambiguousWallTime(t, wall, location) { + return time.Time{}, monitorapi.InputError( + "ambiguous_local_time", + "该当地时刻对应两个时间点;请使用带 UTC 偏移的 RFC3339 时间。", + ) + } + return checkedTime(t, unit) + } + return time.Time{}, invalidTime() +} + +func calendarTime(raw string, location *time.Location, reference time.Time) (string, bool) { + keyword, clock, _ := strings.Cut(raw, " ") + var days int + switch keyword { + case "today", "今天": + days = 0 + case "yesterday", "昨天": + days = -1 + default: + return "", false + } + date := reference.In(location).AddDate(0, 0, days).Format("2006-01-02") + if strings.TrimSpace(clock) != "" { + date += " " + strings.TrimSpace(clock) + } + return date, true +} + +func sameWallTime(a, b time.Time) bool { + const layout = "2006-01-02T15:04:05.999999999" + return a.Format(layout) == b.Format(layout) +} + +func ambiguousWallTime(t, wall time.Time, location *time.Location) bool { + _, offset := t.Zone() + for _, shift := range []time.Duration{-24 * time.Hour, 24 * time.Hour} { + _, other := t.Add(shift).In(location).Zone() + if other != offset { + candidate := t.Add(time.Duration(offset-other) * time.Second).In(location) + if sameWallTime(candidate, wall) { + return true + } + } + } + return false +} + +func checkedTime(t time.Time, unit string) (time.Time, error) { + if t.UTC().Year() < 1 || t.UTC().Year() > 9999 { + return time.Time{}, invalidTime() + } + if t.Nanosecond()%int(timeResolution(unit)) != 0 { + return time.Time{}, monitorapi.InputError("invalid_time_precision", "输入时间的精度超过该工具支持的整秒/整毫秒精度;不会静默舍入。") + } + return t, nil +} + +func encodedTime(p param, t time.Time) (any, error) { + n := t.Unix() + if p.TimeUnit == "ms" { + n = t.UnixMilli() + } + if p.Type == "integer" { + return n, nil + } + if p.Type == "string" { + return strconv.FormatInt(n, 10), nil + } + return nil, monitorapi.InputError("unsupported_time_contract", "工具的时间字段类型不受支持;需要复核工具目录。") +} + +func invalidTime() error { + return monitorapi.InputError( + "invalid_time", + "时间使用整型 Unix 时间戳(可带 s/ms 后缀)、YYYY-MM-DD HH:MM[:SS]、RFC3339、today/yesterday/now 或 今天/昨天/现在;不猜测模糊短语。", + ) +} diff --git a/cmd/system/monitor/time_input_test.go b/cmd/system/monitor/time_input_test.go new file mode 100644 index 0000000..dc1de8f --- /dev/null +++ b/cmd/system/monitor/time_input_test.go @@ -0,0 +1,224 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "strconv" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + systemtest "github.com/TencentBlueKing/bk-cli/cmd/system/testutil" + "github.com/TencentBlueKing/bk-cli/internal/output" +) + +var _ = Describe("explicit monitor time inputs", func() { + var shanghai *time.Location + var reference time.Time + BeforeEach(func() { + var err error + shanghai, err = time.LoadLocation(defaultTimeZone) + Expect(err).NotTo(HaveOccurred()) + reference = time.Date(2026, 10, 14, 12, 30, 0, 0, shanghai) + now = func() time.Time { return reference } + GinkgoT().Setenv("BK_CLI_CONFIG_DIR", GinkgoT().TempDir()) + GinkgoT().Setenv("BK_CLI_MONITOR_PROFILE_FILE", "") + Expect(systemtest.SetupTestContext("https://api.example.com")).To(Succeed()) + }) + AfterEach(func() { now = time.Now }) + + DescribeTable("normalizes equivalent calendar/RFC3339 inputs to the same instant", func(raw string) { + got, err := parseTimeValue(raw, "s", shanghai, reference) + Expect(err).NotTo(HaveOccurred()) + Expect(got.Unix()).To(Equal(time.Date(2026, 10, 13, 14, 0, 0, 0, shanghai).Unix())) + }, + Entry("local minutes", "2026-10-13 14:00"), + Entry("local seconds", "2026-10-13 14:00:00"), + Entry("ISO local", "2026-10-13T14:00:00"), + Entry("explicit offset", "2026-10-13T14:00:00+08:00"), + Entry("UTC", "2026-10-13T06:00:00Z"), + Entry("yesterday clock", "yesterday 14:00"), + Entry("Chinese yesterday clock", "昨天 14:00:00"), + ) + + It("uses the specified zone for local times, but not for offset times or epochs", func() { + local, err := parseTimeValue("2026-10-13 14:00:00", "s", time.UTC, reference) + Expect(err).NotTo(HaveOccurred()) + zoned, err := parseTimeValue("2026-10-13T14:00:00+08:00", "s", time.UTC, reference) + Expect(err).NotTo(HaveOccurred()) + Expect(local.Sub(zoned)).To(Equal(8 * time.Hour)) + unix, err := parseTimeValue(strconv.FormatInt(zoned.Unix(), 10), "s", time.UTC, reference) + Expect(err).NotTo(HaveOccurred()) + Expect(unix.Equal(zoned)).To(BeTrue()) + }) + + It("supports calendar-day endpoints and samples the runtime clock once", func() { + calls := 0 + now = func() time.Time { calls++; return reference.Add(time.Duration(calls-1) * time.Hour) } + cmd := toolCommand("search_logs", true) + setFlags(cmd, "bk_biz_id", "200200", "index_set_id", "101001", "start_time", "今天", "end_time", "现在") + args := dryRunArgs(cmd) + Expect(calls).To(Equal(1)) + Expect( + args["start_time"], + ).To( + Equal(strconv.FormatInt(time.Date(2026, 10, 14, 0, 0, 0, 0, shanghai).Unix(), 10)), + ) + Expect(args["end_time"]).To(Equal(strconv.FormatInt(reference.Unix(), 10))) + }) + + It("converts readable dates and explicit s/ms suffixes to the tool's native unit", func() { + stamp := time.Date(2026, 10, 13, 14, 0, 0, 0, shanghai) + for _, input := range []string{"2026-10-13 14:00:00", strconv.FormatInt(stamp.Unix(), 10) + "s", strconv.FormatInt(stamp.UnixMilli(), 10) + "ms"} { + cmd := toolCommand("execute_sql_query", true) + setFlags( + cmd, + "bk_biz_id", + "200200", + "table_id", + "example.table", + "sql", + "select 1", + "start_time", + input, + "end_time", + "2026-10-13 15:00:00", + ) + args := dryRunArgs(cmd) + Expect(args["start_time"]).To(Equal(strconv.FormatInt(stamp.UnixMilli(), 10))) + } + value, err := parseTimeValue("1000", "ms", shanghai, reference) + Expect(err).NotTo(HaveOccurred()) + Expect(value.Unix()).To(Equal(int64(1))) // No digit heuristic. + }) + + It("sends integer Profiling times for --since as required by its schema", func() { + cmd := toolCommand("get_profile_type", true) + setFlags(cmd, "bk_biz_id", "200200", "app_name", "app", "service_name", "worker", "since", "1h") + args := dryRunArgs(cmd) + Expect(args["start_time"]).To(BeNumerically("==", reference.Add(-time.Hour).Unix())) + Expect(args["start_time"]).NotTo(BeAssignableToTypeOf("")) + }) + + It("normalizes body time fields and retains the named-flag override precedence", func() { + cmd := toolCommand("search_logs", true) + setFlags( + cmd, + "body", + `{"bk_biz_id":"200200","index_set_id":101001,"start_time":"yesterday 10:00","end_time":"yesterday 12:00"}`, + "end_time", + "yesterday 11:00", + ) + args := dryRunArgs(cmd) + Expect( + args["end_time"], + ).To( + Equal(strconv.FormatInt(time.Date(2026, 10, 13, 11, 0, 0, 0, shanghai).Unix(), 10)), + ) + }) + + DescribeTable( + "rejects ambiguous or invalid inputs before sending a request", + func(start, end, zone, code string) { + cmd := toolCommand("search_logs", true) + setFlags( + cmd, + "bk_biz_id", + "200200", + "index_set_id", + "101001", + "start_time", + start, + "end_time", + end, + ) + if zone != "" { + setFlags(cmd, "timezone", zone) + } + _, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal(code)) + }, + Entry("reverse range", "today 11:00", "today 10:00", "", "invalid_time_range"), + Entry("equal range", "today", "today", "", "invalid_time_range"), + Entry("bad date", "2026-02-30", "2026-03-01", "", "invalid_time"), + Entry("vague phrase", "昨天下午", "now", "", "invalid_time"), + Entry("clock without date", "10:00", "now", "", "invalid_time"), + Entry("unknown timezone", "today", "now", "Asia/NotAZone", "invalid_timezone"), + Entry("machine Local zone", "today", "now", "Local", "invalid_timezone"), + Entry( + "millisecond precision into seconds", + "2026-10-13T10:00:00.123+08:00", + "now", + "", + "invalid_time_precision", + ), + Entry("overflow", "9223372036854775807", "now", "", "invalid_time"), + Entry("log range too long", "2026-10-11", "2026-10-13", "", "invalid_time_range"), + Entry( + "DST missing hour", + "2025-03-09 02:30:00", + "2025-03-09 04:00:00", + "America/New_York", + "nonexistent_local_time", + ), + Entry( + "DST repeated hour", + "2025-11-02 01:30:00", + "2025-11-02 03:00:00", + "America/New_York", + "ambiguous_local_time", + ), + ) + + It("allows explicit offsets to disambiguate a repeated local hour", func() { + zone, err := time.LoadLocation("America/New_York") + Expect(err).NotTo(HaveOccurred()) + a, err := parseTimeValue("2025-11-02T01:30:00-04:00", "s", zone, reference) + Expect(err).NotTo(HaveOccurred()) + b, err := parseTimeValue("2025-11-02T01:30:00-05:00", "s", zone, reference) + Expect(err).NotTo(HaveOccurred()) + Expect(b.Sub(a)).To(Equal(time.Hour)) + }) + + It("allows exact milliseconds but never silently truncates finer precision", func() { + value, err := parseTimeValue("2026-10-13 14:00:00.1200", "ms", shanghai, reference) + Expect(err).NotTo(HaveOccurred()) + Expect(value.Nanosecond()).To(Equal(120000000)) + _, err = parseTimeValue("2026-10-13T14:00:00.1234+08:00", "ms", shanghai, reference) + Expect(err).To(MatchError(ContainSubstring("invalid_time_precision"))) + }) + + It("rejects --since with body endpoints and too-small relative windows", func() { + cmd := toolCommand("search_logs", true) + setFlags( + cmd, + "body", + `{"bk_biz_id":"200200","index_set_id":101001,"start_time":"yesterday","end_time":"today"}`, + "since", + "1h", + ) + _, err := runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("conflicting_time")) + cmd = toolCommand("search_logs", true) + setFlags(cmd, "bk_biz_id", "200200", "index_set_id", "101001", "since", "0.1s") + _, err = runProfileCommand(cmd) + Expect(err.(*output.CLIError).Code).To(Equal("invalid_since")) + }) +}) diff --git a/cmd/system/monitor/tool_command.go b/cmd/system/monitor/tool_command.go new file mode 100644 index 0000000..bd63430 --- /dev/null +++ b/cmd/system/monitor/tool_command.go @@ -0,0 +1,355 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + syslib "github.com/TencentBlueKing/bk-cli/internal/system" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +var now = time.Now + +const sinceFlag = "since" + +func newToolCmd(item tool, deps systemcmd.BuildDeps) *cobra.Command { + var body, since string + hasWindow := hasParam(item, "start_time") && hasParam(item, "end_time") + cmd := &cobra.Command{ + Use: item.Name, + Short: item.Short, + Long: longHelp(item, hasWindow), + Example: example(item, hasWindow), + Args: cobra.NoArgs, + RunE: runMonitor(deps, func(cmd *cobra.Command, rt *monitorapi.Runtime) error { + args, err := toolArgs(cmd, item, body, since, hasWindow) + if err != nil { + return err + } + reply, err := monitorapi.Execute(rt, item.Name, args) + if err != nil { + return preservePermissionTarget( + err, + rt.ContextName, + item.Name, + jsonText(args["bk_biz_id"]), + args, + ) + } + return printReply(cmd, reply, nil) + }), + } + addProfileFlags(cmd) + for _, p := range item.Params { + addParamFlag(cmd, p) + } + addConvenienceFlags(cmd, item) + if hasTimeInput(item) { + cmd.Flags().String( + "timezone", + defaultTimeZone, + "IANA zone for wall-clock dates and today/yesterday; RFC3339 offsets and Unix timestamps are absolute", + ) + } + if hasWindow { + cmd.Flags().StringVar( + &since, + sinceFlag, + "", + "Recent time window ending now, such as 15m or 1h (max 24h); fills start_time/end_time", + ) + } + cmd.Flags().StringVar( + &body, + syslib.ActionBodyFlagName, + "", + "Full tool_args JSON, @file, or - for stdin; named flags override matching keys", + ) + return cmd +} + +func addParamFlag(cmd *cobra.Command, p param) { + usage := p.Description + if p.Required { + usage = "[Required] " + usage + } + if len(p.Enum) > 0 { + usage += "; values: " + strings.Join(p.Enum, ", ") + } + switch { + case p.Type == "array" && isScalar(p.Items): + usage += "; repeat or comma-separate" + cmd.Flags().StringArray(p.Name, nil, usage) + case p.Type == "array" || p.Type == "object": + usage += "; JSON" + cmd.Flags().String(p.Name, "", usage) + case p.Type == "boolean": + cmd.Flags().String(p.Name, "", usage+"; true or false") + case p.TimeUnit != "": + cmd.Flags().String( + p.Name, + "", + usage+"; datetime/RFC3339/today/yesterday/now or Unix "+timeUnitName( + p.TimeUnit, + )+" (optional s/ms suffix)", + ) + default: + cmd.Flags().String(p.Name, "", usage) + } +} + +// toolArgs merges --body with named flags, converts only by the reviewed schema type, and leaves +// all ownership and permission checks to the server. +func toolArgs(cmd *cobra.Command, item tool, body, since string, hasWindow bool) (map[string]any, error) { + args := map[string]any{} + if body != "" { + raw, err := readBody(cmd, body) + if err != nil { + return nil, err + } + if err := jsonObject(raw, &args); err != nil { + return nil, err + } + } + for _, p := range item.Params { + flag := cmd.Flags().Lookup(p.Name) + if flag == nil || !flag.Changed { + continue + } + value, err := convertFlag(cmd, p) + if err != nil { + return nil, err + } + args[p.Name] = value + } + scene, _ := cmd.Flags().GetString(sceneFlag) + where, _ := cmd.Flags().GetStringArray(whereFlag) + if err := profileArgs(currentProfile(cmd), item, args, scene, where); err != nil { + return nil, err + } + if err := applyConvenienceFlags(cmd, item, args); err != nil { + return nil, err + } + if err := applyTimeInputs(cmd, item, args, since); err != nil { + return nil, err + } + var missing []string + for _, p := range item.Params { + if p.Required && args[p.Name] == nil { + missing = append(missing, "--"+p.Name) + } + } + if len(missing) > 0 { + next := monitorapi.NextCommands(item.Name, fmt.Sprint(args["bk_biz_id"])) + hint := "缺少必填参数:" + strings.Join(missing, ", ") + if hasWindow && (args["start_time"] == nil || args["end_time"] == nil) { + hint += ";时间可用 --since 15m" + } + return nil, monitorapi.InputError("missing_arguments", hint+"。", next...) + } + recordProfileArgs(currentProfile(cmd), args) + return args, nil +} + +func readBody(cmd *cobra.Command, body string) ([]byte, error) { + var data []byte + var err error + switch { + case body == "-": + data, err = readAllLimited(cmd.InOrStdin()) + case strings.HasPrefix(body, "@"): + data, err = readFileLimited(strings.TrimPrefix(body, "@")) + default: + data = []byte(body) + } + if err != nil || len(data) == 0 || len(data) > maxInputBytes { + return nil, monitorapi.InputError("invalid_body", "无法读取 --body,或内容为空、超过 64 KiB。") + } + return data, nil +} + +func convertFlag(cmd *cobra.Command, p param) (any, error) { + if p.TimeUnit != "" { + raw, _ := cmd.Flags().GetString(p.Name) + return raw, nil + } + if p.Type == "array" && isScalar(p.Items) { + values, _ := cmd.Flags().GetStringArray(p.Name) + var items []any + for _, raw := range values { + for part := range strings.SplitSeq(raw, ",") { + if part = strings.TrimSpace(part); part != "" { + value, err := scalar(p.Name, p.Items, part) + if err != nil { + return nil, err + } + items = append(items, value) + } + } + } + return items, nil + } + raw, _ := cmd.Flags().GetString(p.Name) + if p.Type == "array" || p.Type == "object" { + var value any + if err := decodeJSON([]byte(raw), &value); err != nil { + return nil, monitorapi.InputError("invalid_json_flag", "--"+p.Name+" 必须是 JSON。") + } + if (p.Type == "array" && monitorapi.List(value) == nil) || + (p.Type == "object" && monitorapi.Map(value) == nil) { + return nil, monitorapi.InputError("invalid_json_flag", "--"+p.Name+" 的 JSON 类型必须是 "+p.Type+"。") + } + return value, nil + } + return scalar(p.Name, p.Type, raw) +} + +func scalar(name, kind, raw string) (any, error) { + switch kind { + case "integer": + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil, monitorapi.InputError("invalid_integer", "--"+name+" 必须是整数。") + } + return value, nil + case "number": + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil, monitorapi.InputError("invalid_number", "--"+name+" 必须是数字。") + } + return value, nil + case "boolean": + value, err := strconv.ParseBool(raw) + if err != nil { + return nil, monitorapi.InputError("invalid_boolean", "--"+name+" 只能是 true 或 false。") + } + return value, nil + default: + return raw, nil + } +} + +func longHelp(item tool, hasWindow bool) string { + lines := []string{ + item.Short + ".", + "", + "Monitor API tool: " + item.Name + " (" + item.Category + ").", + "Each run is checked again by bk-monitor for identity, resource ownership and permission.", + "Use --profile NAME for SRE-provided resource bindings; inspect with monitor profile show --profile NAME.", + } + if len(item.Prerequisites) > 0 { + lines = append( + lines, + "If an identifier is missing, get it from: "+strings.Join(item.Prerequisites, ", ")+".", + ) + } + if hasWindow { + lines = append( + lines, + "Use --since for a recent window OR --start_time/--end_time for explicit endpoints (including body).", + ) + } + if hasTimeInput(item) { + lines = append( + lines, + "Time input: YYYY-MM-DD HH:MM[:SS], RFC3339, today/yesterday/now (今天/昨天/现在), or integer Unix timestamps.", + "Wall-clock default: Asia/Shanghai; override with --timezone. Explicit RFC3339 offsets take precedence.", + "Unsuffixed timestamps use "+timeUnitName( + timeUnit(item), + )+"; s/ms suffixes explicitly convert units. No digit-count guessing.", + "Only catalog top-level time fields are converted; query_list and --from anchor timestamps remain native.", + ) + } + lines = append(lines, "Use --body for the full tool_args JSON; named flags override matching keys.", "") + lines = append(lines, permissionLines(item)...) + return strings.Join(lines, "\n") +} + +func example(item tool, hasWindow bool) string { + if text := curatedExample(item.Name); text != "" { + return text + } + parts := []string{"bk-cli monitor", item.Module, item.Name} + for _, p := range item.Params { + if !p.Required || (hasWindow && (p.Name == "start_time" || p.Name == "end_time")) { + continue + } + parts = append(parts, "--"+p.Name, placeholder(p)) + } + if hasWindow { + parts = append(parts, "--since", "15m") + } + return " " + strings.Join(parts, " ") +} + +func placeholder(p param) string { + switch { + case p.Name == "bk_biz_id": + return "" + case p.Type == "array" && isScalar(p.Items): + if len(p.Enum) > 0 { + return p.Enum[0] + } + return "" + case p.Type == "array": + return "'[...]'" + case p.Type == "object": + return "'{...}'" + case len(p.Enum) > 0: + return p.Enum[0] + default: + return "<" + p.Name + ">" + } +} + +func hasParam(item tool, name string) bool { + for _, p := range item.Params { + if p.Name == name { + return true + } + } + return false +} + +func timeUnit(item tool) string { + for _, p := range item.Params { + if p.Name == "start_time" && p.TimeUnit != "" { + return p.TimeUnit + } + } + return "s" +} + +func timeUnitName(unit string) string { + if unit == "ms" { + return "milliseconds" + } + return "seconds" +} + +func isScalar(kind string) bool { + return kind == "string" || kind == "integer" || kind == "number" || kind == "boolean" +} diff --git a/cmd/system/monitor/tools.go b/cmd/system/monitor/tools.go new file mode 100644 index 0000000..8a9cbd0 --- /dev/null +++ b/cmd/system/monitor/tools.go @@ -0,0 +1,296 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package monitor + +import ( + "fmt" + "maps" + + "github.com/spf13/cobra" + + "github.com/TencentBlueKing/bk-cli/cmd/system/internal/monitorapi" + "github.com/TencentBlueKing/bk-cli/internal/output" + systemcmd "github.com/TencentBlueKing/bk-cli/internal/systemcmd" +) + +func newToolsSpec(byName map[string]tool) systemcmd.SystemSpec { + return systemcmd.SystemSpec{ + Name: "tools", + Description: "Search tools or read one server contract", + RegisterGoActions: func(parent *cobra.Command, deps systemcmd.BuildDeps) error { + parent.AddCommand(newSearchToolsCmd(deps), newSchemaCmd(deps, byName)) + return nil + }, + } +} + +func newSearchToolsCmd(deps systemcmd.BuildDeps) *cobra.Command { + var biz, category, capability string + var availableOnly bool + cmd := &cobra.Command{ + Use: "search", + Short: "Search Monitor API tools for one business", + Long: `Search tools for one business. + +permission_state=requires_resource means a concrete resource is still needed. +It is neither denial nor grant. Use monitor permission check after choosing the resource.`, + Example: " bk-cli monitor tools search --bk_biz_id --category log\n" + + " bk-cli monitor tools search --bk_biz_id --capability query", + Args: cobra.NoArgs, + RunE: runMonitor(deps, func(cmd *cobra.Command, rt *monitorapi.Runtime) error { + if err := monitorapi.RequireBiz(biz); err != nil { + return err + } + body := map[string]any{"bk_biz_id": biz, "available_only": availableOnly, "page_size": 100} + if category != "" { + body["category"] = categoryFor(category) + } + if capability != "" { + body["capability"] = capability + } + reply, err := monitorapi.Call(rt, "lookup_tool", body) + if err != nil || reply.IsDryRun() { + return printReply(cmd, reply, err) + } + data := monitorapi.Map(reply.Data) + for _, item := range monitorapi.List(data["tools"]) { + card := monitorapi.Map(item) + if command := commandForTool(card); command != "" { + card["command"] = command + } + } + return writeMonitorEnvelope(cmd, reply.Envelope(data)) + }), + } + addProfileFlags(cmd) + cmd.Flags().StringVar(&biz, "bk_biz_id", "", "Business ID") + cmd.Flags().StringVar(&category, "category", "", "CLI module or API category, such as log or alert") + cmd.Flags().StringVar( + &capability, + "capability", + "", + "Capability: discovery, query, analysis, detail, relation, mutation, export", + ) + cmd.Flags().BoolVar( + &availableOnly, + "available_only", + false, + "Hide tools explicitly denied; keep requires_resource", + ) + return cmd +} + +func newSchemaCmd(deps systemcmd.BuildDeps, byName map[string]tool) *cobra.Command { + var name string + cmd := &cobra.Command{ + Use: "schema", + Short: "Show the current server contract for one tool", + Example: " bk-cli monitor tools schema --tool search_logs", + Args: cobra.NoArgs, + RunE: runMonitor(deps, func(cmd *cobra.Command, rt *monitorapi.Runtime) error { + if _, ok := byName[name]; !ok { + return monitorapi.InputError( + "invalid_tool", + "未知的只读工具;先运行 bk-cli monitor tools search --bk_biz_id 。", + ) + } + reply, err := monitorapi.Call(rt, "lookup_tool_schema", map[string]any{"tool_name": name}) + return printReply(cmd, reply, err) + }), + } + addProfileFlags(cmd) + cmd.Flags().StringVar(&name, "tool", "", "Monitor API tool name") + return cmd +} + +// check runs the Unified Tool Search decision chain without executing the data tool. +func check(rt *monitorapi.Runtime, biz, name string, values map[string]any) (map[string]any, error) { + var steps []map[string]any + catalog, err := monitorapi.Call( + rt, + "lookup_tool", + map[string]any{"tool_name": name, "bk_biz_id": biz, "available_only": false}, + ) + if err != nil { + return nil, err + } + if catalog.IsDryRun() { + return nil, monitorapi.InputError( + "dry_run_not_supported", + "permission check/apply 需要真实服务端判定,不支持 --dry-run。", + ) + } + cards := monitorapi.List(monitorapi.Map(catalog.Data)["tools"]) + var card map[string]any + if len(cards) > 0 { + card = monitorapi.Map(cards[0]) + } + if card["name"] != name { + return nil, &monitorapi.Decision{ + Code: "catalog_mismatch", + Message: "工具目录没有返回该精确工具。", + ExitCode: output.ExitCodeSystemError, + RequestID: catalog.RequestID, + } + } + if card["risk"] != "query" || card["requires_confirmation"] != false { + return nil, &monitorapi.Decision{ + Code: "readonly_contract_changed", + Message: "服务端工具不再是只读契约,已停止。", + ExitCode: output.ExitCodeSystemError, + RequestID: catalog.RequestID, + } + } + steps = append(steps, map[string]any{ + "step": "tool_search", "request_id": catalog.RequestID, "permission_state": card["permission_state"], + "required_context": card["required_context"], "prerequisites": card["prerequisites"], + }) + schema, err := monitorapi.Call(rt, "lookup_tool_schema", map[string]any{"tool_name": name}) + if err != nil { + return nil, err + } + contract := monitorapi.Map(schema.Data) + context, unused := monitorapi.ResourceContext(contract, values) + permission := monitorapi.Map(contract["permission"]) + steps = append(steps, map[string]any{ + "step": "contract", + "request_id": schema.RequestID, + "catalog_version": contract["catalog_version"], + "permission_mode": permission["mode"], + "fallback_on": permission["fallback_on"], + "ignored_fields": unused, + }) + body := map[string]any{"tool_name": name, "bk_biz_id": biz, "include_apply_guide": true} + if len(context) > 0 { + body["resource_context"] = context + } + decision, step, err := permissionDecision(rt, name, body, context, permission) + if err != nil { + return nil, err + } + steps = append(steps, step) + if decision != nil { + if monitorapi.IsResourceDecision(decision.Code) { + decision.Next = nextCommands(name, biz) + } + decision.Steps = steps + return nil, preservePermissionTarget(decision, rt.ContextName, name, biz, context) + } + return map[string]any{ + "decision": "ready", "tool": name, "bk_biz_id": biz, "steps": steps, + "next": commandName(name) + " --help", + "meaning": "服务端预检通过。真正执行时仍会重新鉴权;结果不缓存、不当凭据。", + }, nil +} + +func permissionDecision( + rt *monitorapi.Runtime, + name string, + body, context, permission map[string]any, +) (*monitorapi.Decision, map[string]any, error) { + reply, err := monitorapi.Call(rt, "lookup_permissions", body) + if err != nil { + judgment, ok := monitorapi.IsJudgment(err) + if !ok { + return nil, nil, err + } + return judgment, map[string]any{ + "step": "permission", + "request_id": judgment.RequestID, + "resource_context": context, + "decision": judgment.Code, + }, nil + } + data := monitorapi.Map(reply.Data) + var state map[string]any + matches := 0 + for _, item := range monitorapi.List(data["scopes"]) { + if scope := monitorapi.Map(item); scope["tool_name"] == name { + state = scope + matches++ + } + } + state = normalizeLegacyPermissionState(state, permission, body["bk_biz_id"]) + authorized, hasAuthorization := data["authorized"].(bool) + granted := state["state"] == "granted" || state["state"] == "exempt" + if matches != 1 || len(monitorapi.List(data["scopes"])) != 1 || !hasAuthorization || authorized != granted || + (authorized && len(monitorapi.List(data["missing_permissions"])) > 0) || + (state["authorized"] != nil && state["authorized"] != authorized) { + return nil, nil, &monitorapi.Decision{ + Code: "permission_response_invalid", Message: "权限响应没有唯一匹配当前工具,或授权结论互相矛盾;已停止。", + ExitCode: output.ExitCodeSystemError, RequestID: reply.RequestID, + } + } + step := map[string]any{ + "step": "permission", "request_id": reply.RequestID, "resource_context": context, + "state": state["state"], "authorization_source": state["authorization_source"], + } + if authorized { + return nil, step, nil + } + decision := monitorapi.StateDecision(state) + if decision == nil { + decision = monitorapi.StateDecision(map[string]any{}) + } + decision.RequestID = reply.RequestID + return decision, step, nil +} + +// Older space-scoped APIs return an explicit authorization boolean without a state. +// Accept that contract only when the server schema, action and business all match. +// This normalizes a server decision; it never derives permission from the catalog. +func normalizeLegacyPermissionState(scope, permission map[string]any, biz any) map[string]any { + if _, exists := scope["state"]; exists { + return scope + } + action, hasAction := permission["action_id"].(string) + authorized, hasAuthorization := scope["authorized"].(bool) + _, hasMode := permission["mode"] + resource := monitorapi.Map(scope["resource"]) + if hasMode || !hasAction || action == "" || !hasAuthorization || + permission["resource_type"] != "space" || permission["resource_arg"] != "bk_biz_id" || + scope["action_id"] != action || biz == nil || resource["bk_biz_id"] == nil || + fmt.Sprint(resource["bk_biz_id"]) != fmt.Sprint(biz) { + return scope + } + state := maps.Clone(scope) + state["state"] = "missing" + if authorized { + state["state"] = "granted" + } + state["authorization_source"] = "legacy_mcp" + return state +} + +func categoryFor(value string) string { + for _, item := range modules { + if item.Name == value || item.Category == value { + return item.Category + } + } + return value +} + +func commandForTool(card map[string]any) string { + name, _ := card["name"].(string) + if card["risk"] != "query" || card["requires_confirmation"] != false { + return "" + } + return commandName(name) +} diff --git a/cmd/system/register.go b/cmd/system/register.go index bffe1ea..71d4b7a 100644 --- a/cmd/system/register.go +++ b/cmd/system/register.go @@ -67,6 +67,7 @@ func systemCatalog() []systemcmd.SystemSpec { newGSESystemSpec(), newDevopsSystemSpec(), newNodemanSystemSpec(), + newMonitorSystemSpec(), } } diff --git a/cmd/system/register_test.go b/cmd/system/register_test.go index 23b97ee..6562a22 100644 --- a/cmd/system/register_test.go +++ b/cmd/system/register_test.go @@ -891,6 +891,21 @@ actions: {"devops", "stream", "trigger"}, {"nodeman", "install_job"}, {"nodeman", "get_job_details"}, + {"monitor", "tools", "search"}, + {"monitor", "permission", "list"}, + {"monitor", "permission", "check"}, + {"monitor", "permission", "apply"}, + {"monitor", "profile", "list"}, + {"monitor", "profile", "show"}, + {"monitor", "profile", "check"}, + {"monitor", "status"}, + {"monitor", "space", "search_spaces"}, + {"monitor", "log", "search_logs"}, + {"monitor", "log", "search_index_set_context"}, + {"monitor", "metric", "execute_range_query"}, + {"monitor", "alert", "list_alerts"}, + {"monitor", "apm", "search_spans"}, + {"monitor", "extract", "get_log_extract_task"}, } { actionCmd, _, findErr := root.Find(path) Expect(findErr).NotTo(HaveOccurred(), strings.Join(path, " ")) diff --git a/docs/faq.md b/docs/faq.md index 1477fe6..d664813 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -60,7 +60,7 @@ skills 所在目录:[bk-cli/skills](https://github.com/TencentBlueKing/bk-cli/ context 本身是逻辑上的设计,可以用于隔离: -- 作为环境隔离,例如内部上云一个 context,bkop 一个 context,在同一台机器上用 `bk-cli` 操作多个环境 +- 作为环境隔离,例如测试环境和生产环境各使用一个 context,在同一台机器上用 `bk-cli` 操作多个环境 - 作为账号隔离,可以登录不同的账号(应用身份/用户身份),调用时按需切换 ### `"context \"default\" already exists"` diff --git a/docs/monitor.md b/docs/monitor.md new file mode 100644 index 0000000..e996fc1 --- /dev/null +++ b/docs/monitor.md @@ -0,0 +1,78 @@ +# monitor Profile 初始化 + +本文供人工准备观测配置;查询、权限诊断和排障由 [monitor Skill](../skills/bk-cli-monitor/SKILL.md) 提供。使用前须安装包含 `monitor` 的构建,并按 [共享使用规则](../skills/bk-cli-shared/SKILL.md) 配置 context、使用自己的身份登录。 + +## 1. 准备资源 + +由 SRE 或业务维护者确认要使用的 context、业务、日志索引集、指标表和 APM 应用。资源 ID 来自当前账号可见的目录;不要把示例标识当成真实资源。 + +Profile 只保存资源默认值,不保存凭据、不提供授权,也不会修改当前激活的 context。共享 Profile 不代表共享权限,不共享凭据目录。 + +## 2. 单来源配置 + +复制 [V1 模板](../skills/bk-cli-monitor/references/profiles.yaml),按模板替换 context 和已确认的资源。配置结构示例: + +```yaml +version: 1 +profiles: + checkout-prod: + context: observability + bk_biz_id: "100100" + log: + index_set_id: 101001 + metric: + table_id: example.request_metrics + apm: + app_name: example-app +``` + +这里的 context、业务和资源均为虚构示例。配置中的 context 必须已存在且已登录。 + +```bash +export BK_CLI_MONITOR_PROFILE_FILE=./team-profiles.yaml +bk-cli monitor profile list +bk-cli monitor profile show --profile checkout-prod +bk-cli monitor profile check --profile checkout-prod +bk-cli monitor status --profile checkout-prod +``` + +也可在单次命令中指定 `--profile_file ./team-profiles.yaml`。离线查看配置不证明认证或权限有效;在线检查也不读取日志正文或执行预设查询。 + +## 3. 多组件与多来源 + +当日志、指标、Trace 位于不同 context 或业务,使用 [V2 模板](../skills/bk-cli-monitor/references/profiles-v2.yaml)。V2 分为: + +| 字段 | 用途 | +| --- | --- | +| `sources` | 引用已有 context 与业务,不填写 URL 或凭据 | +| `components` | 描述后端、Worker 等被观测组件 | +| `bindings` | 为模块配置资源及具名查询,引用一个 source | +| `args` | 该绑定的公共资源默认值 | +| `tools` | 精确工具的预设,覆盖同名公共默认值 | + +```bash +bk-cli monitor profile show --profile_file ./multi-source-profiles.yaml \ + --profile game-production --component backend + +bk-cli monitor profile check --profile_file ./multi-source-profiles.yaml \ + --profile game-production +``` + +多个组件或绑定有歧义时,明确指定 `--component` / `--binding`。显式 context 或业务与 Profile 冲突时停止,不静默切换来源。完整格式、覆盖规则和交付步骤见 [Profile 使用与交付](../skills/bk-cli-monitor/references/profiles.md)。 + +## 4. 判断检查结果 + +- `metadata`:检查了该工具最终解析资源的元数据;不证明所有查询表达式有效。 +- `permission_only`:只完成权限预检,未完成资源元数据验证。 +- `fail`:按结果中的错误及下一步处理;不要扩大权限或换来源掩盖错误。 +- `warn` / `skipped`:分别表示警告或未检查,不等于通过。 + +预检不会执行 SQL、PromQL 或 Span 检索。工具级预设覆盖表或索引时,检查的必须是覆盖后的同一资源。 + +## 5. 查询与维护 + +首次交付或配置变更后检查资源;正常查询不需要每次先预检,服务端会在执行时重新鉴权。通用 `--stage`、`--header` 同样作用于中间检查请求;使用 `--jq` 时可读取最终结果及 Profile 来源信息。 + +日期、RFC3339、相对时间和单位转换见 [查询时间](../skills/bk-cli-monitor/references/time-inputs.md)。Profile 不固定运行时查询窗口,不支持脚本、插值或远程 include。保存的查询结果可能含业务内容,不提交到源码仓库或公开文档。 + +模块能力、完整查询示例和错误处理统一见 [monitor Skill](../skills/bk-cli-monitor/SKILL.md)。 diff --git a/docs/user-guide.md b/docs/user-guide.md index 556945c..10059be 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -177,7 +177,7 @@ bk-cli auth login \ --bk_app_secret='your_secret' \ --bk_ticket='your_bk_ticket' -# 其他版本网关,例如 bk-dev/bkop/sg/bk2game 等 +# 使用 bk_token 的网关部署 bk-cli auth login \ --bk_app_code='your_app' \ diff --git a/internal/output/jq.go b/internal/output/jq.go index 2f3b043..e05b6bb 100644 --- a/internal/output/jq.go +++ b/internal/output/jq.go @@ -82,9 +82,13 @@ func AddJQFlag(cmd *cobra.Command) { // WriteCommandEnvelope projects only successful final envelopes. Projection // failures preserve the completed request's original result; I/O failures stop. func WriteCommandEnvelope(cmd *cobra.Command, env *Envelope) error { + return writeCommandEnvelope(cmd, env, env) +} + +func writeCommandEnvelope(cmd *cobra.Command, env *Envelope, document any) error { flag := cmd.Flags().Lookup("jq") if flag == nil || !flag.Changed || !env.OK { - return env.WriteJSON(cmd.OutOrStdout()) + return writeFinalDocument(cmd.OutOrStdout(), document) } value, ok := flag.Value.(*jqValue) if !ok || value.code == nil { @@ -94,9 +98,9 @@ func WriteCommandEnvelope(cmd *cobra.Command, env *Envelope) error { if ctx == nil { ctx = context.Background() } - projected, err := value.project(ctx, env) + projected, err := value.project(ctx, document) if err != nil { - if writeErr := env.WriteJSON(cmd.OutOrStdout()); writeErr != nil { + if writeErr := writeFinalDocument(cmd.OutOrStdout(), document); writeErr != nil { return writeErr } var cliErr *CLIError @@ -128,7 +132,7 @@ func WriteCommandEnvelope(cmd *cobra.Command, env *Envelope) error { return err } -func (v *jqValue) project(parent context.Context, env *Envelope) ([]byte, error) { +func (v *jqValue) project(parent context.Context, env any) ([]byte, error) { ctx, cancel := context.WithTimeout(parent, jqTimeout) defer cancel() data, err := json.Marshal(env) diff --git a/internal/output/metadata.go b/internal/output/metadata.go new file mode 100644 index 0000000..a29a4db --- /dev/null +++ b/internal/output/metadata.go @@ -0,0 +1,62 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package output + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/spf13/cobra" +) + +// WriteCommandEnvelopeWithMetadata adds command-local provenance to the complete +// final document before applying the existing jq/error/output contract. Core fields +// cannot be replaced, and ordinary envelope writers keep their original behavior. +func WriteCommandEnvelopeWithMetadata(cmd *cobra.Command, env *Envelope, fields map[string]any) error { + raw, err := json.Marshal(env) + if err != nil { + return err + } + document := map[string]json.RawMessage{} + if err := json.Unmarshal(raw, &document); err != nil { + return err + } + for key, value := range fields { + switch key { + case "ok", "status", "headers", "data", "message", "dry_run", "request", "error": + return fmt.Errorf("metadata cannot replace envelope field %q", key) + } + encoded, err := json.Marshal(value) + if err != nil { + return err + } + document[key] = encoded + } + return writeCommandEnvelope(cmd, env, document) +} + +func writeFinalDocument(writer io.Writer, document any) error { + if env, ok := document.(*Envelope); ok { + return env.WriteJSON(writer) + } + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(document) +} diff --git a/internal/output/metadata_test.go b/internal/output/metadata_test.go new file mode 100644 index 0000000..2e74d43 --- /dev/null +++ b/internal/output/metadata_test.go @@ -0,0 +1,92 @@ +/* + * TencentBlueKing is pleased to support the open source community by making + * 蓝鲸智云 - bk-cli (BlueKing - Cli) available. + * Copyright (C) Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://opensource.org/licenses/MIT + * + * 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. + * + * We undertake not to change the open source license (MIT license) applicable + * to the current version of the project delivered to anyone in the future. + */ + +package output + +import ( + "bytes" + "encoding/json" + "io" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" +) + +type metadataFailWriter struct{} + +func (metadataFailWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +var _ = Describe("final envelope metadata", func() { + It("projects metadata and preserves large integer literals", func() { + cmd := &cobra.Command{} + AddJQFlag(cmd) + Expect(cmd.Flags().Set("jq", `{source:.source,id:.data.id + 1}`)).To(Succeed()) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + env := SuccessData(map[string]any{"id": json.Number("9007199254740993")}) + Expect(WriteCommandEnvelopeWithMetadata(cmd, env, map[string]any{"source": "sample"})).To(Succeed()) + Expect(stdout.String()).To(Equal("{\"id\":9007199254740994,\"source\":\"sample\"}\n")) + }) + It("keeps the original complete failed envelope rather than applying jq", func() { + cmd := &cobra.Command{} + AddJQFlag(cmd) + Expect(cmd.Flags().Set("jq", "empty")).To(Succeed()) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + Expect( + WriteCommandEnvelopeWithMetadata( + cmd, + Err("denied", "denied", ""), + map[string]any{"source": "sample"}, + ), + ).To( + Succeed(), + ) + Expect(stdout.String()).To(ContainSubstring(`"ok": false`)) + Expect(stdout.String()).To(ContainSubstring(`"source": "sample"`)) + }) + It("rejects metadata that could overwrite the success or error contract", func() { + Expect( + WriteCommandEnvelopeWithMetadata(&cobra.Command{}, Success("ok"), map[string]any{"ok": false}), + ).To( + MatchError(ContainSubstring("cannot replace")), + ) + }) + It("rejects non-JSON metadata without writing a partial envelope", func() { + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + Expect( + WriteCommandEnvelopeWithMetadata(cmd, Success("ok"), map[string]any{"source": func() {}}), + ).To( + HaveOccurred(), + ) + Expect(out.Len()).To(BeZero()) + Expect(WriteCommandEnvelopeWithMetadata(cmd, SuccessData(func() {}), nil)).To(HaveOccurred()) + }) + It("propagates output failures", func() { + cmd := &cobra.Command{} + cmd.SetOut(metadataFailWriter{}) + Expect( + WriteCommandEnvelopeWithMetadata(cmd, Success("ok"), map[string]any{"source": "sample"}), + ).To( + MatchError(io.ErrClosedPipe), + ) + }) +}) diff --git a/internal/update/additional_test.go b/internal/update/additional_test.go index e29913c..29fe73f 100644 --- a/internal/update/additional_test.go +++ b/internal/update/additional_test.go @@ -20,6 +20,7 @@ package update import ( "context" + "errors" "net/http" "net/http/httptest" "runtime" @@ -30,7 +31,11 @@ import ( var _ = Describe("GitHubSource", func() { BeforeEach(func() { + originalClient := githubHTTPClient githubHTTPClient = &http.Client{} + DeferCleanup(func() { + githubHTTPClient = originalClient + }) }) It("checks the latest release successfully", func() { @@ -78,6 +83,45 @@ var _ = Describe("GitHubSource", func() { Expect(err).To(MatchError(ContainSubstring("version check returned status 502"))) }) + It("uses the default API endpoint and preserves transport errors", func() { + transportErr := errors.New("transport unavailable") + githubHTTPClient = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + Expect(req.Method).To(Equal(http.MethodGet)) + Expect(req.URL.Scheme).To(Equal("https")) + Expect(req.URL.Host).To(Equal("api.github.com")) + Expect(req.URL.Path).To(Equal("/repos/TencentBlueKing/bk-cli/releases/latest")) + return nil, transportErr + }), + } + + source := &GitHubSource{Owner: "TencentBlueKing", Repo: "bk-cli"} + info, err := source.CheckLatest(context.Background()) + Expect(info).To(BeNil()) + Expect(err).To(MatchError(ContainSubstring("failed to check for updates"))) + Expect(errors.Is(err, transportErr)).To(BeTrue()) + }) + + DescribeTable("rejects incomplete release metadata", func(payload, message string) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(payload)) + })) + DeferCleanup(server.Close) + githubHTTPClient = server.Client() + + source := &GitHubSource{Owner: "TencentBlueKing", Repo: "bk-cli", APIBase: server.URL} + info, err := source.CheckLatest(context.Background()) + Expect(info).To(BeNil()) + Expect(err).To(MatchError(ContainSubstring(message))) + }, + Entry("missing version tag", `{"assets":[]}`, "latest release has no tag name"), + Entry("matching asset without a download URL", + `{"tag_name":"v1.2.3","assets":[{"name":"`+ + expectedReleaseAssetName("v1.2.3", runtime.GOOS, runtime.GOARCH)+`"}]}`, + "has no download URL"), + ) + It("returns an error when the request URL is invalid", func() { source := &GitHubSource{Owner: "TencentBlueKing", Repo: "bk-cli", APIBase: "://bad-url"} _, err := source.CheckLatest(context.Background()) @@ -102,7 +146,11 @@ var _ = Describe("GitHubSource", func() { It("returns an error when the platform asset is missing", func() { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"tag_name":"v1.2.3","assets":[{"name":"bk-cli_v1.2.3_darwin_amd64.tar.gz","browser_download_url":"https://example.com/other"}]}`)) + _, _ = w.Write( + []byte( + `{"tag_name":"v1.2.3","assets":[{"name":"bk-cli_v1.2.3_darwin_amd64.tar.gz","browser_download_url":"https://example.com/other"}]}`, + ), + ) })) DeferCleanup(server.Close) diff --git a/skills/bk-cli-monitor/SKILL.md b/skills/bk-cli-monitor/SKILL.md new file mode 100644 index 0000000..8e80b99 --- /dev/null +++ b/skills/bk-cli-monitor/SKILL.md @@ -0,0 +1,331 @@ +--- +name: bk-cli-monitor +description: 当需要通过 `bk-cli monitor` 以用户权限查询蓝鲸观测数据时使用:业务空间、指标、日志、告警、事件、APM/Trace/Profiling、仪表盘、关联关系、告警配置、日志采集或日志提取状态。用户给出 bk_biz_id、告警 ID、索引集 ID、APM 应用、trace_id、指标表等标识,或要求判断某个观测工具能否执行时,优先使用本 skill。 +--- + +# bk-cli monitor — 使用与排障 + +开始前读取 [共享使用规则](../bk-cli-shared/SKILL.md)。本 Skill 面向执行观测任务的 Agent;人工准备配置见 [Profile 初始化](../../docs/monitor.md)。资源标识必须来自用户已确认的 Profile 或当前账号可见目录,不猜测业务、表、索引集、应用或 Trace ID。 + + +`bk-cli monitor` 面向研发、SRE 和自动化脚本,提供日志、指标、告警、调用链等观测数据的统一查询入口。用户可以在终端完成排查,也可以把同样的命令交给脚本或 Agent 使用,无需为每种数据分别编写接口调用。 + +它适合三类日常工作: + +- **查问题**:检索错误日志、查看指标变化,从告警或单次调用继续查相关数据。 +- **复用查询**:通过团队维护的 Profile 保存常用数据来源和查询参数,减少反复查找业务、索引集与应用名称。 +- **接入自动化**:取得结构化 JSON 结果和明确的失败状态,供巡检脚本、排障工具或 Agent 处理。 + +> 使用前须具备包含 `monitor` 的构建、已登录的数据来源和对应资源权限。配置和预检均不替代执行时的服务端鉴权。 + +## 1. 快速开始:优先使用团队 Profile + +Profile 是一份有名字的观测配置。SRE 提前确认业务、日志来源、指标表和 APM 应用,使用者选择名称即可,不必每次手工填写这些标识。 + +假设团队提供了 `team-profiles.yaml`,其中有名为 `checkout-prod` 的 Profile: + +```bash +# 指定本次终端使用的观测配置文件 +export BK_CLI_MONITOR_PROFILE_FILE=./team-profiles.yaml + +# 看有哪些配置,再确认目标业务和数据来源 +bk-cli monitor profile list +bk-cli monitor profile show --profile checkout-prod + +# 查询最近 15 分钟的错误日志,最多返回 20 条 +bk-cli monitor log search_logs --profile checkout-prod \ + --query_string 'ERROR OR WARN' --since 15m --limit 20 +``` + +文件名和 Profile 名称都是示例,以团队实际交付为准。也可以在单次命令中使用 `--profile_file ./team-profiles.yaml`,不设置环境变量。 + +首次使用或配置更新后,可运行以下检查: + +```bash +bk-cli monitor profile check --profile checkout-prod +``` + +它检查工具契约、权限和资源元数据,不读取日志正文,也不证明每条查询表达式都有效。普通查询不需要每次先检查;真正执行时,服务端仍会重新鉴权。 + +### 一个业务有多个组件或数据来源怎么办? + +V2 Profile 可以分别配置后端、Worker 等组件,并把日志、指标和 Trace 指向各自的数据来源。同类指标还可以命名为 `traffic`、`latency` 等查询绑定。 + +```bash +# 示例 V2 文件由团队另行提供;先查看 backend 组件的配置 +bk-cli monitor profile show --profile_file ./multi-source-profiles.yaml \ + --profile game-production --component backend + +# 执行该组件预设的 traffic 指标查询 +bk-cli monitor metric execute_range_query \ + --profile_file ./multi-source-profiles.yaml \ + --profile game-production --component backend --binding traffic --since 1h +``` + +- 有多个组件时,明确指定 `--component`;同一模块有多个绑定时,明确指定 `--binding`。 +- 缺少配置或选择有歧义时会停止,不自动切换业务或数据来源。 +- Profile **不是凭据,也不是授权**。共享配置文件不代表共享访问权限,更不能共享凭据目录。 + +配置格式、模板及覆盖规则见 [Profile 使用与交付](references/profiles.md)。 + +## 2. 没有 Profile:先找资源,再查询 + +命令统一采用以下结构,日志位于 `monitor log`,不是独立的 `bk-cli log`: + +```text +bk-cli monitor <模块> <工具> [参数] +``` + +下文用引号包住的 `<业务ID>`、`<索引集ID>` 等都是占位符,执行前替换为当前账号可见的真实值。不要直接照抄示例标识,也不要让 Agent 猜测资源 ID。 + +```bash +# 按名称查找业务空间 +bk-cli monitor space search_spaces --space_name '<业务名称关键词>' + +# 查看该业务的日志索引集 +bk-cli monitor log list_index_sets --bk_biz_id '<业务ID>' + +# 确认所选索引集有哪些可查询字段 +bk-cli monitor log get_index_set_fields \ + --bk_biz_id '<业务ID>' --index_set_id '<索引集ID>' + +# 开始检索 +bk-cli monitor log search_logs \ + --bk_biz_id '<业务ID>' --index_set_id '<索引集ID>' \ + --query_string 'ERROR OR WARN' --since 15m --limit 20 +``` + +出现多个候选时,应结合业务名称、数据来源和采集范围选择,不把列表第一项当作默认答案。已知正确标识时可以直接查询,不必每次重复发现资源。 + +## 3. 典型排查场景 + +### 3.1 日志:从关键词定位到上下文 + +日志支持按索引集检索,也支持按已配置的场景检索。查询词、时间范围、返回条数和字段过滤都可以显式指定。 + +对于容器日志,要区分“选择哪些日志来源”和“筛选来源中的哪些记录”: + +| 参数 | 用途 | 示例 | +| --- | --- | --- | +| `--scene` | 选择日志场景 | `--scene k8s`;场景须由目标服务支持 | +| `--where` | 按场景标签选择数据来源 | `--where 'cluster_id=<集群ID>'` | +| `--filter` | 对日志行做字段短语过滤 | `--filter '__ext.io_kubernetes_pod_namespace=checkout-prod'` | +| `--query_string` | 输入关键词或复杂查询表达式 | `--query_string 'ERROR OR WARN'` | + +表中的集群、命名空间及字段名仅说明写法。实际场景和字段应从 `list_log_scenes`、`get_scene_log_fields` 或 `get_index_set_fields` 获取。多个 `--filter` 按 AND 组合,再与查询词组合;它们不是权限隔离规则。 + +需要查看一条命中日志前后的内容时,先保存该记录,再查询上下文: + +```bash +bk-cli monitor log search_logs \ + --bk_biz_id '<业务ID>' --index_set_id '<索引集ID>' \ + --query_string ERROR --since 15m --limit 1 > hit.json + +bk-cli monitor log search_index_set_context \ + --bk_biz_id '<业务ID>' --from hit.json --size 5 +``` + +`hit.json` 必须来自成功且有命中的检索结果,并保留原始定位字段;不能用模拟记录、猜测的时间戳或其他业务的记录替代。`--size 5` 是每个方向的条数限制,不是总返回条数。 + +**日志结果可能包含业务内容。** 保存文件时使用授权的位置,不把它提交到代码仓库或公开文档。 + +### 3.2 指标:查看一段时间内的变化 + +指标支持 PromQL 范围查询,也提供指标组、字段目录与 SQL 查询入口。可以用于查看错误率、耗时、吞吐等变化,具体取决于业务已接入的指标。 + +```bash +# 查看可用的指标组;这不是所有监控数据的总目录 +bk-cli monitor metric list_time_series_groups --bk_biz_id '<业务ID>' + +# 查看选定指标表的字段 +bk-cli monitor metric list_time_series_metrics \ + --bk_biz_id '<业务ID>' --table_id '<指标表ID>' + +# 按已确认的表达式查询最近一小时,每分钟一个查询步长 +bk-cli monitor metric execute_range_query \ + --bk_biz_id '<业务ID>' --promql '' --since 1h --step 1m +``` + +CLI 不会根据表名自动生成或改写 PromQL。需要固定查询口径时,可由 SRE 在 V2 Profile 中维护具名查询;指标含义和计算方式仍需由业务确认。 + +### 3.3 告警:从告警详情继续查关联日志 + +告警列表和 TopN 查询限定在最终选择的业务中。列表返回的告警必须属于同一业务;归属不一致或无法确认时,CLI 会拒绝输出结果。后续详情使用该业务列表返回的告警 ID,不根据返回内容自动切换业务。 + +```bash +# 最近一小时仍处于异常状态的告警 +bk-cli monitor alert list_alerts \ + --bk_biz_id '<业务ID>' --status ABNORMAL --since 1h + +# 查看一条告警的详情 +bk-cli monitor alert get_alert_info \ + --bk_biz_id '<业务ID>' --id '<告警ID>' + +# 获取该告警关联的日志查询目标 +bk-cli monitor alert get_alert_log_relations \ + --bk_biz_id '<业务ID>' --alert_id '<告警ID>' +``` + +关联查询返回的是日志目标和索引信息,不是日志正文。继续使用日志命令,并结合告警发生时间缩小查询范围。 + +### 3.4 调用链:从一次调用查看完整 Trace + +APM(应用性能监控)提供应用、服务、Span、Trace 和性能剖析查询。Span 是一次调用记录,Trace 是由多个 Span 组成的调用链。 + +```bash +# 查看当前业务的 APM 应用 +bk-cli monitor apm list_apm_applications --bk_biz_id '<业务ID>' + +# 检索某个应用最近 15 分钟的调用记录 +bk-cli monitor apm search_spans \ + --bk_biz_id '<业务ID>' --app_name '<应用名称>' --since 15m --limit 20 + +# 使用查询返回的 trace_id 查看调用链 +bk-cli monitor apm get_trace_detail \ + --bk_biz_id '<业务ID>' --app_name '<应用名称>' --trace_id '' +``` + +应用内检索不会自动限定到某个服务。需要服务级过滤时,应使用该工具支持的过滤条件,或使用团队已验证的 V2 工具预设;不能假定所有 APM 工具都接受同一个服务参数。 + +## 4. 时间范围:优先使用相对时间或可读日期 + +常用写法: + +| 输入 | 含义 | +| --- | --- | +| `--since 15m` | 从本次执行时刻向前查询 15 分钟 | +| `--since 1h30m` | 最近 90 分钟 | +| `--start_time 'yesterday 10:00' --end_time 'yesterday 11:00'` | 昨天 10 点到 11 点 | +| `--start_time today --end_time now` | 今天零点到本次执行时刻 | +| 带 `Z` 或 `+08:00` 的 RFC3339 时间 | 明确时区偏移的绝对时刻 | + +例如,按昨天的固定时段查询日志: + +```bash +bk-cli monitor log search_logs \ + --bk_biz_id '<业务ID>' --index_set_id '<索引集ID>' \ + --start_time 'yesterday 10:00' --end_time 'yesterday 11:00' --limit 20 +``` + +- 无时区偏移的日期默认按 **Asia/Shanghai(北京时间)** 解释,可用 `--timezone UTC` 等名称覆盖。 +- `--since` 与显式起止时间二选一,包括 `--body` 中的时间;不会静默覆盖。 +- `--since` 最大 24 小时,日志单次窗口也不超过 24 小时;其他查询还受服务端保留期和查询限制约束。 +- 裸整数按具体工具的秒或毫秒单位解释,不按位数猜测。日期格式通常更容易核对。 +- 资源关联批次中的嵌套时间、日志上下文定位字段不适用上述自动转换规则。 + +完整格式和边界见 [查询时间](references/time-inputs.md)。 + +## 5. 能力范围与命令发现 + +当前提供 **11 个业务模块、65 个只读工具**。下表是命令覆盖范围,不表示每个工具在所有部署中都可用;实际可用性还取决于服务版本、数据接入和当前用户权限。 + +| 模块 | 工具数 | 可以查询什么 | +| --- | ---: | --- | +| `log` | 9 | 日志场景、索引集、字段、日志、上下文及聚类结果 | +| `metric` | 4 | 指标组、字段、PromQL 和 SQL 查询 | +| `alert` | 12 | 告警、详情、事件和关联观测数据 | +| `apm` | 11 | APM 应用、服务、Span、Trace 和性能剖析 | +| `event` | 3 | 自定义事件和日志关键字事件 | +| `relation` | 2 | 资源关系的即时和时间段查询 | +| `space` | 2 | 业务空间和集群目录 | +| `dashboard` | 2 | 仪表盘目录及详情 | +| `alarm` | 8 | 告警策略、屏蔽、通知组及处理套餐配置,只读查看 | +| `collect` | 8 | 日志采集、索引集分组及清洗预览,只读查看 | +| `extract` | 4 | 日志提取范围及任务状态,不发起提取或下载 | + +另外提供 `tools`、`permission`、`profile` 和 `status` 辅助命令,不计入上述 65 个工具。 + +不知道用哪个工具,或不确定参数时: + +```bash +# 按业务和类别查看服务端工具目录 +bk-cli monitor tools search --bk_biz_id '<业务ID>' --category log + +# 从服务端读取某个工具的当前参数契约 +bk-cli monitor tools schema --tool search_logs + +# 查看本地命令参数、示例和所需权限 +bk-cli monitor log search_logs --help +``` + +目录结果中的 `command` 表示已提供的 CLI 入口。服务端目录可能包含尚未封装的工具,不能据此推断 CLI 支持任意工具执行。 + +## 6. 权限与问题定位 + +### 有权限才可查询;缺权限时给出下一步 + +```bash +# 查看当前用户在业务下的日志权限状态 +bk-cli monitor permission list --bk_biz_id '<业务ID>' --module log --missing_only + +# 检查具体工具与索引集的权限,不读取日志正文 +bk-cli monitor permission check --bk_biz_id '<业务ID>' \ + --tool get_index_set_fields --body '{"index_set_id":<索引集ID>}' + +# 获取申请入口,不会自动提交申请 +bk-cli monitor permission apply --bk_biz_id '<业务ID>' --tool search_logs \ + --body '{"target_type":"index_set","index_set_id":<索引集ID>}' +``` + +这里 JSON 中的索引集 ID 应替换为整数,不带引号。上述单工具 `permission check/apply` 需要服务端判定,不支持 `--dry-run`。 + +CLI 会根据服务端结果说明缺少哪类权限,并返回可用的申请链接。用户自行打开链接申请;预检通过和取得链接都不等于已获得授权。具体工具支持的 MCP 权限与原生权限路径以其帮助和服务端契约为准。 + +旧版业务权限 API 可能只返回 `authorized`,没有 `state`。CLI 仅在服务端 Schema 明确为旧版业务权限、工具唯一、Action 与业务匹配且授权结论一致时兼容;冲突响应仍拒绝。SQL 使用 BkBase 结果表,可从同业务 `collect list_result_tables --scenario_id bkdata` 发现,不应将时序分组表当作 BkBase 表;预检通过不代表查询或平台表归属校验已通过。 + +### 根据错误类型处理,而不是盲目重试 + +| 提示 | 应当怎么做 | +| --- | --- | +| `needs_resource` | 补齐索引集、应用等具体资源;这不等于无权 | +| `permission_missing` / `permission_denied` | 查看权限诊断和服务端申请指引 | +| `resource_not_visible` / `resource_not_found` | 从当前账号可见目录重新核对资源,必要时联系维护者 | +| `resource_blocked` | 联系维护者核查资源元数据,不应反复申请更多权限 | +| `profile_context_mismatch` / `profile_business_mismatch` | Profile 与显式范围冲突,确认配置后再查 | +| `needs_component` / `needs_binding` | 用 `profile show` 查看配置,再明确选择 | +| `response_scope_mismatch` | 列表业务归属与请求不一致或无法确认,核对 Profile、业务选择和服务端接口 | +| `invalid_response` | 接口响应缺少约定结构,联系维护者核对接口契约 | +| `api_not_found` | 核对 stage、网关前台路径、后端映射和服务路由/版本 | +| `response_too_large` | 缩小查询范围或减少字段;仅发现事件来源时传 `--return_dimensions=false` | +| `relation_route_unavailable` | 业务缺可用关联存储路由,联系平台管理员检查数据接入和路由 | +| `retry_later` | 服务端没有成功返回;不能据此断言权限拒绝,保留请求 ID 排查 | +| `permission_response_invalid` | 权限响应仍不一致或无法确定目标,保持停止状态,不绕过预检 | +| `auth_failed` | 在对应数据来源更新登录状态;不通过换账号或换来源掩盖错误 | + +可用 `status` 检查当前状态,或先做离线检查: + +```bash +bk-cli monitor status +bk-cli monitor status --offline +``` + +离线检查不会验证登录是否有效;`skipped` 表示未检查,`warn` 也不等于该项通过。带 `--profile` 的在线状态检查还会检查该配置的资源元数据。 + +## 7. 结果使用与产品边界 + +- **输出可供程序处理。** 命令返回结构化 JSON,包含成功状态和数据;错误带有错误码,适用时提供下一步命令。脚本应同时检查退出码及结果,不能只看是否输出了 JSON。 +- **可先预览请求。** 在查询命令后加 `--dry-run`,检查实际业务、资源和时间范围,不发送查询;这不证明权限或查询表达式有效。带 `now` / `since` 的预览与执行会分别取时钟,精确复现时应固定两个时间边界。 +- **空结果不是“没有故障”。** 它只表示本次范围与条件没有命中;CLI 不会自动扩大时间、换索引或切换数据来源。 +- **只提供只读入口。** 当前不创建或修改告警策略、采集配置、屏蔽规则,不启动任务或提供文件导出下载。只读命令也不表示用户凭据本身只有读取权限。 +- **不替代分析判断。** CLI 提供数据和可复用查询,不保证自动得出根因;指标口径、过滤条件和处置动作仍需结合业务确认。 +- **不强制迁移。** 它可以与观测页面及已有 MCP 入口并行使用;共同遵守服务端权限,但不代表不同入口的登录状态和配置可直接互换。 + +建议从一个明确业务、一个短时间窗和一种数据开始,确认来源与结果后,再加入其他观测数据或自动化流程。 + +## 通用请求参数与输出 + +业务命令逐一调用对应的 HTTP API;GET 参数位于 query,POST 参数位于 JSON body。`--body` 接受完整业务参数对象、`@file` 或 stdin;同名具名参数覆盖对应字段。结构化参数按当前命令帮助传 JSON。 + +- `--stage` 选择网关 stage,默认 `prod`;context 选择部署,两者独立。 +- `--header` 可重复,支持本次请求的租户和其他 header 覆盖;相同选项也用于权限与 Profile 预检的中间请求。认证规则和脱敏约定见共享 Skill。 +- `--jq` 投影最终完整 envelope,包含显式 Profile 的来源信息,不在中间请求上执行。表达式语法错误在请求前失败;投影执行失败会保留完整原结果并返回非零,不重发查询。 + +```bash +bk-cli monitor log search_logs --profile checkout-prod --since 15m \ + --stage testing --header 'X-Example:probe' --jq '.data' + +bk-cli monitor log search_logs --profile checkout-prod --since 15m \ + --dry-run --jq '{source:.profile.name,args:.request.body}' +``` + +Profile `check` 验证当前工具最终解析出的资源。工具预设覆盖公共 `table_id` 时,元数据检查必须指向覆盖后的表;`permission_only` 表示没有完成该资源的元数据验证,不能写成资源已验证或 SQL 已执行。 diff --git a/skills/bk-cli-monitor/references/profiles-v2.yaml b/skills/bk-cli-monitor/references/profiles-v2.yaml new file mode 100644 index 0000000..5ca02d2 --- /dev/null +++ b/skills/bk-cli-monitor/references/profiles-v2.yaml @@ -0,0 +1,58 @@ +# Synthetic template. Replace context names, business IDs and all resources with verified values. +version: 2 +sources: + game-logs: {context: logs-prod, bk_biz_id: "100100"} + game-observe: {context: observe-prod, bk_biz_id: "200200"} +profiles: + game-production: + description: 游戏生产环境;各类观测数据可以在不同部署/业务 + environment: production + components: + backend: + bindings: + logs: + module: log + source: game-logs + args: {index_set_id: 101001} + traces: &shared-traces + module: apm + source: game-observe + args: {app_name: example-game} + tools: + list_apm_services: {service_names: [api]} + search_spans: + filters: [{key: resource.service.name, operator: equal, value: [api]}] + traffic: + module: metric + source: game-observe + args: {table_id: example.request_metrics} + tools: + execute_range_query: {promql: 'sum(rate(request_total{service="api"}[5m]))'} + latency: + module: metric + source: game-observe + args: {table_id: example.latency_metrics} + tools: + execute_range_query: {promql: 'max(request_latency_ms{service="api"})'} + policy: + module: alarm + source: game-observe + tools: + get_alarm_strategy: + conditions: [{key: strategy_id, value: ["301001"]}] + dashboard: + module: dashboard + source: game-observe + args: {dashboard_uid: example-dashboard} + worker: + bindings: + logs: + module: log + source: game-logs + args: {index_set_id: 101002} + traces: + <<: *shared-traces + tools: + list_apm_services: {service_names: [worker]} + search_spans: + filters: [{key: resource.service.name, operator: equal, value: [worker]}] diff --git a/skills/bk-cli-monitor/references/profiles.md b/skills/bk-cli-monitor/references/profiles.md new file mode 100644 index 0000000..d65b2b4 --- /dev/null +++ b/skills/bk-cli-monitor/references/profiles.md @@ -0,0 +1,120 @@ +# 用 Profile 交付业务观测能力 + +Profile 是 SRE 维护的具名资源配置:研发和 Agent 选择一个名字,CLI 确定性地补齐业务、日志来源、指标表和 APM 应用。不再每次让 Agent 查询索引列表、猜关联关系。它不是凭据、IAM 授权或自动记忆。 + +## 研发:拿到文件后怎样使用 + +先按共享 Skill 配好 SRE 指定名称的 bk-cli context,并用自己的账号登录。Profile 只引用 context 名称,不创建 context、不保存 Token,也不修改当前激活环境。 + +```bash +export BK_CLI_MONITOR_PROFILE_FILE=./team-profiles.yaml +bk-cli monitor profile list +bk-cli monitor profile show --profile checkout-prod +bk-cli monitor status --profile checkout-prod +bk-cli monitor log search_logs --profile checkout-prod --since 15m --query_string ERROR --limit 5 +bk-cli monitor metric list_time_series_metrics --profile checkout-prod +``` + +业务首次接入或配置更新后运行 `status`/`profile check`;普通查询无需每次预检。请求仍由服务端实时鉴权,预检结果不是缓存凭据。 + +只检查权限、不读取资源字段目录: + +```bash +bk-cli monitor permission check --profile checkout-prod --tool search_logs +bk-cli monitor permission apply --profile checkout-prod --tool search_logs +``` + +`apply` 只返回 IAM 链接,不提交申请。模块级 `permission list/apply` 仍是业务级判断,不枚举 Profile 中每一个资源;检查具体资源用 `--tool`。 + +## V2:业务组件和多个数据来源 + +异构业务使用 `version: 2`:顶层 `sources` 引用已有 context + 业务 ID;Profile 下按 `components` 组织业务组件,每个组件用具名 `bindings` 绑定一个 module 和 source。 + +```bash +bk-cli monitor profile show --profile game-production --component backend +bk-cli monitor log search_logs --profile game-production --component backend --since 15m +bk-cli monitor metric execute_range_query --profile game-production --component backend --binding traffic --since 1h +``` + +多组件必须指定 `--component`,同 module 有多个绑定必须指定 `--binding`;缺失的能力不回退到其他组件或默认环境。Source 冲突依旧停止,不修改 active context。 + +- binding 的 `args` 只放跨工具含义稳定的资源字段,如索引集、应用、指标表、仪表盘 UID。 +- 其他固定参数放在 `tools.<精确工具名>`;不得引用写入/导出工具、未知参数或预填时间/关联批次。优先级:显式 flag > body > 精确工具预设 > 公共资源参数。 +- `options.scene/where/filters` 仅供日志绑定使用;不要把日志的过滤语法套给 APM、事件或告警。 +- `status/check` 分 Source 和 binding 报告;`permission_only` 没有执行数据查询,`metadata` 也不证明每条表达式有效。一次最多预检 20 个工具,可用组件/绑定缩小范围。 +- V2 诊断的 `--dry-run` 只输出本地计划。`list/show` 不要求已登录;离线状态不证明 Token 有效。 + +示例见 [profiles-v2.yaml](profiles-v2.yaml),其中标识全是示例,必须由 SRE 按实时目录替换。V1 继续兼容,适合简单单来源。 + +## SRE:V1 文件格式与分发 + +读取随 CLI 打包的示例(标识均为示例,必须替换): + +```bash +bk-cli skills read bk-cli-monitor references/profiles.yaml --raw > team-profiles.yaml +``` + +V1 文件为 `version: 1` 和 `profiles` 名称映射。完整示例见同目录 `profiles.yaml`。每个 Profile 包含: + +| 字段 | 用途 | +|---|---| +| `description` | 可选,描述业务对象、场景和使用边界 | +| `context` | 必填,本机已配置的 bk-cli 部署名称;不是网关 stage | +| `bk_biz_id` | 必填,非零整数;负数空间 ID 也支持,建议写成字符串 | +| `log.index_set_id` | 固定索引集,与 `log.scene` 二选一 | +| `log.conditions` | 可选,索引集 `search_logs` 的结构化过滤对象;与查询词按上游规则组合 | +| `log.scene` | 场景标识,从 `list_log_scenes` 获取 | +| `log.where` | 选表路由标签,如 `cluster_id`;不是日志行过滤,不把 namespace 放在这里 | +| `log.filters` | `search_logs` 的日志行字段短语过滤,按 AND 组合;namespace 等字段从日志字段目录获得 | +| `metric.table_id` | 只补给接受 `table_id` 的指标字段/SQL 工具 | +| `apm.app_name` | APM 应用,补给接受该字段的 APM 工具 | +| `apm.service_name` | 可选,只补给有 `service_name` 或 `service_names` 参数的工具 | + +同业务不同 namespace 用不同的 Profile 名称。需要复用配置时可使用 YAML 原生锚点和 `<<`;合并是浅层的,覆盖整个 `log` 块时需写全该块,不引入自定义继承、模板、远程 include 或脚本。 + +文件查找顺序:显式 `--profile_file` → `BK_CLI_MONITOR_PROFILE_FILE` → `${BK_CLI_CONFIG_DIR:-~/.bk-cli}/monitor/profiles.yaml`。必须显式选择 `--profile NAME`;没有“上次使用”或默认 Profile。文件最大 64 KiB、1–100 个 Profile、单个 YAML 文档;未知字段、重复键和混合日志模式会报错。 + +SRE 按业务仓库或内部配置渠道维护文件,业务研发只用自己的凭据。可以共享 Profile 文件和业务 Skill,**不能共享 `credentials.enc` 或整个 CLI 凭据目录**。真实业务 ID、内网地址和应用名称不写入开源示例。 + +## 覆盖和安全边界 + +- 参数只补给声明了该字段的工具;不会把日志 `conditions` 错塞到告警过滤中。 +- 普通参数优先级:显式 flag > `--body` > Profile。`conditions` 或 `table_id_conditions` 是整体替换,不尝试深合并布尔表达式。 +- 场景 `--where KEY=VALUE` 按 key 覆盖选表路由,不能与显式 `table_id_conditions` 混用。日志行过滤用 `log.filters` 或可重复的 `--filter FIELD=VALUE`;显式 filter 按 key 覆盖 Profile 并保留其他字段,最终与 `query_string` 按 AND 组合。 +- `filters` 的值会转义为字段短语;文本字段的匹配行为仍由服务端字段映射决定。复杂正则、范围或 OR 条件用 `--query_string`,CLI 不推断查询语言。 +- `--context`、`bk_biz_id`、日志模式或 `scene` 与 Profile 冲突会停止,避免在另一环境/业务复用同一个数字 ID。需要改这些范围时换 Profile 或不带 `--profile`。 +- 显式 `index_set_id`、`table_id` 等可覆盖默认值;Profile 是默认参数,不是不可绕过的访问控制。限制访问必须由服务端 IAM 实现。 +- `metric.table_id` **不改写 PromQL**。不同指标有不同的标签语义,namespace 标签和查询表达式仍须显式给出。 +- 没有 `service_name`/`service_names` 参数的 APM 工具不会自动限定服务。`log.conditions` 和 `log.filters` 只作用于 `search_logs`(conditions 仅索引集模式);不自动转写聚类、分析、字段目录或上下文工具的过滤格式。 +- Profile 不能证明资源仍存在,查询为空也不会自动换索引。新配置通过预检后,SRE 仍需执行一个小时间窗的真实查询验收。 + +使用 Profile 的输出会附加顶层 `profile` 字段,含文件路径、SHA-256、绑定 context、业务、实际补齐字段和 `resolved_parameters`(最终资源参数);原有 `data` 形状不变,`--from` 仍能读取检索结果。使用 `--dry-run` 查看最终请求体,确认显式覆盖后的范围。 + +## 自检与排障 + +```bash +bk-cli monitor profile check --profile checkout-prod +bk-cli monitor status --profile checkout-prod +bk-cli monitor status --profile checkout-prod --offline +bk-cli monitor status --check_update +``` + +- `profile check`:先执行工具契约/权限预检,再读取索引字段、场景字段、指标字段或 APM 服务目录。不会读取日志正文、告警内容或指标时序;也不证明每条过滤表达式、namespace 值和所有工具都有效。 +- `status`:显示版本、本地 context、凭据文件;在线时发一次鉴权请求、对比内置/服务端目录版本。带 Profile 时再做上述资源检查。 +- `--offline` 或 `status --dry-run`:完全不联网,不解密凭据;`skipped` 表示没有验证,不能说 Token 有效。 +- `--check_update`:可选查询官方 GitHub Release,不下载、不安装;发布源不可达、预览版本不可比或目录版本不一致都是 `warn`,不伪装成“最新”。不检查 npm 发布渠道。 +- 检查失败会保留 stdout 诊断报告并非零退出;`warn` 不导致失败。不要只看顶层 `ok`,也要看每条检查是 `pass`、`warn` 还是 `skipped`。 + +`profile_not_found` 先查 `profile list`;`profile_context_mismatch` / `profile_business_mismatch` 不要靠自动切换绕过;资源不可见请让 SRE 核对配置;`permission_missing` 用 `permission apply`;`auth_failed` 在绑定 context 下重新登录。 + +## FDE 交付清单 + +SRE 面向业务定制的最小交付是:一个已核对资源的 Profile 文件、一段场景 Skill,以及真实查询验收记录。Skill 应写业务工作流,不再复制 ID 关联表。例如: + +```text +使用 bk-cli-monitor Skill;Profile 文件使用团队提供的 team-profiles.yaml。 +排查生产结算服务时选 --profile checkout-prod,从最近 15 分钟日志与告警开始。 +缺权限按 CLI 指引申请;配置失效联系 SRE,不猜索引、不把空结果当无故障。 +``` + +交付前:确认 context 指向的数据来源(可能不等于故障所在环境);核对业务和资源目录;运行 `profile check` 与小窗口查询;换成研发本人的账号核验权限;确认文件没有凭据。CLI/Skill 可共用,Profile 随业务维护,不为每个业务复制一套 CLI。 diff --git a/skills/bk-cli-monitor/references/profiles.yaml b/skills/bk-cli-monitor/references/profiles.yaml new file mode 100644 index 0000000..a8aa43f --- /dev/null +++ b/skills/bk-cli-monitor/references/profiles.yaml @@ -0,0 +1,33 @@ +# Illustrative only: replace context, business and resource identifiers with verified values. +# No credentials, URLs, scripts, templates or IAM grants belong in this file. +version: 1 +profiles: + checkout-prod: &checkout + description: Checkout service, production namespace + context: observability + bk_biz_id: "100100" + log: + scene: k8s + where: + cluster_id: EXAMPLE-CLUSTER + filters: + __ext.io_kubernetes_pod_namespace: checkout-prod + metric: + table_id: example.checkout_metrics + apm: + app_name: checkout + service_name: api + checkout-staging: + <<: *checkout + description: Same application, staging namespace + log: + scene: k8s + where: + cluster_id: EXAMPLE-CLUSTER + filters: + __ext.io_kubernetes_pod_namespace: checkout-staging + checkout-worker: + <<: *checkout + description: Worker index-set logs, not scene mode + log: + index_set_id: 200200 diff --git a/skills/bk-cli-monitor/references/time-inputs.md b/skills/bk-cli-monitor/references/time-inputs.md new file mode 100644 index 0000000..9cbfaa4 --- /dev/null +++ b/skills/bk-cli-monitor/references/time-inputs.md @@ -0,0 +1,87 @@ +# 查询时间:相对窗口、日期时间与时间戳 + +只有工具契约声明的顶层 `start_time` / `end_time` 才使用这里的转换。时间来自执行 CLI 的机器时钟,不由 Agent 编造 epoch;Profile 不固定查询日期。 + +## 两种输入模式 + +- 最近一段时间:`--since 15m`、`--since 1h30m`,结束点是本次执行时刻,最大 24h。 +- 明确时间边界:`--start_time VALUE --end_time VALUE`。支持日期时间、RFC3339、日历关键词、整型时间戳。 + +两种模式互斥,包括 `--body` 中的时间字段;不会用 since 悄悄覆盖已有边界。显式命名 start/end 仍覆盖 body 中的同名字段。 + +## 可直接输入的格式 + +| 写法 | 含义 | +|---|---| +| `2026-10-13 14:00:00` / `2026-10-13 14:00` | 所选时区的日期时间 | +| `2026-10-13T14:00:00` | 无偏移的 ISO 本地时间,仍使用所选时区 | +| `2026-10-13` | 所选时区当天 00:00:00 | +| `2026-10-13T14:00:00+08:00` / `2026-10-13T06:00:00Z` | 明确的绝对时刻;字符串中的偏移优先于 timezone | +| `today` / `今天` | 所选时区今天 00:00 | +| `yesterday` / `昨天` | 所选时区昨天 00:00,不是现在减 24h | +| `yesterday 10:00` / `昨天 10:00:00` | 指定日历日的时刻 | +| `now` / `现在` | 本次 CLI 取到的实际时钟,按工具精度落到整秒/整毫秒 | +| `1791871200` | 原生单位的 Unix 整数;本例用于秒接口 | +| `1791871200s` / `1791871200000ms` | 显式单位,同一个绝对时刻;CLI 转成目标工具的单位 | + +默认时区固定为 **Asia/Shanghai(北京时间)**,不依赖电脑的本地时区。用 `--timezone UTC` 或其他 IANA 名称覆盖。不支持含糊的 `Local`。 + +`--since` 是经过的时长,Unix 时间戳本身也是绝对时刻,时区不改变它们。时区只影响无偏移的日期和日历关键词。 + +## 实际命令 + +以下在已配置的 `game-production` Profile 下执行;日期示例仅演示格式,查询前注意数据保留期。 + +```bash +# 最近 90 分钟 +bk-cli monitor log search_logs --profile game-production --component worker --since 1h30m --limit 3 + +# 昨天 10 点到 11 点,默认北京时间 +bk-cli monitor log search_logs --profile game-production --component worker \ + --start_time 'yesterday 10:00' --end_time 'yesterday 11:00' --limit 3 + +# 固定日期时间 +bk-cli monitor log search_logs --profile game-production --component worker \ + --start_time '2026-10-13 14:00:00' --end_time '2026-10-13 15:00:00' --limit 3 + +# 明确使用 UTC;也可直接传带 Z 或 +08:00 的 RFC3339 字符串 +bk-cli monitor metric execute_range_query --profile game-production --component backend \ + --binding latency --start_time '2026-10-13 06:00' --end_time '2026-10-13 07:00' --timezone UTC + +# 时间戳,显式写单位最不容易用错 +bk-cli monitor log search_logs --profile game-production --component worker \ + --start_time 1791871200s --end_time 1791874800s --limit 3 + +# 今天零点至今;不是滚动 24 小时 +bk-cli monitor apm search_spans --profile game-production --component backend \ + --start_time today --end_time now --limit 3 +``` + +`--body` 内的顶层时间也会转换;timezone 只作为 CLI flag,不放进 API body: + +```bash +bk-cli monitor log search_logs --profile game-production --component worker \ + --body '{"start_time":"昨天 10:00","end_time":"昨天 11:00","limit":3}' +``` + +先加 `--dry-run` 查看 `request.body.tool_args` 中最终发送的 Unix 值和 JSON 类型。使用 now/since 的预览和之后的执行会各自取时钟;需要完全复现同一窗口时,固定两个绝对边界。 + +## 秒、毫秒与 JSON 类型 + +- **秒,字符串**:日志检索/字段分析、PromQL、告警、事件、Span 检索、APM calculate_by_range 等顶层时间参数。 +- **毫秒,字符串**:`metric execute_sql_query`、`log search_log_clustering_pattern`。 +- **秒,整数**:`apm get_profile_label`、`get_profile_type`、`query_graph_profile`。CLI 对日期、since 和 body 都按 Schema 生成整数,不再发送字符串。 +- `alert get_alert_event_tag_detail` 只有顶层 start_time,支持上述输入,但没有 since/end_time。 + +裸整数按工具原生单位解释,**不按数字位数猜秒/毫秒**。例如 SQL 的 `1791871200` 会被解释成毫秒;想输入秒请加 `s` 后缀或使用日期格式。使用毫秒接口时,日期可精确到毫秒;秒接口不接受非整秒值,不静默舍入。纳秒时间戳和小数形式的 Unix 时间戳不在本地解析范围内。 + +## 例外和错误边界 + +- `relation find_relations` 的时间在 `query_list[].timestamp`;`find_relations_range` 在 `query_list[].start_time/end_time`。它们仍按 Schema 传整数秒,不支持本地 since、timezone 或嵌套自然时间转换。 +- `search_index_set_context --from` 是读取原始日志定位记录,不是“开始时间”。`dtEventTimeStamp` 等定位字段原样保留,不在本次时间转换范围内。 +- 自然关键词仅支持上述明确写法。不解析“昨天下午”“上周五”“一小时前”;单独的 `10:00` 也不猜日期。最近一小时使用 since,具体时刻写完整日期或日历关键词。 +- since 不支持 `1d` / `1w`,一天用 `24h`。时间精度也必须符合工具单位,例如秒接口不能用 `--since 0.1s`。 +- start 必须早于 end;没有填齐必需边界时不会猜另一个边界。日志单次窗口最大 24h,其他工具的跨度、保留期和查询量仍由各服务限制,不保证任意历史窗口都有数据。 +- 夏令时切换时,不存在或重复的当地时刻会报错,要求显式 RFC3339 偏移。自然日可能是 23/25 小时,因此 `yesterday → today` 不总等于 since 24h;超过日志窗口上限时需自行拆分。 +- 时间范围末端是否包含某个数据点以具体工具为准;CLI 不统一改成半开区间,也不自动加减 1 秒。跨窗口汇总时按实际接口规则去重。 +- 非法时区、时间格式、精度丢失、反向区间和 since 冲突均在发请求前失败;不会因查不到数据而自动扩大时间或切换索引。 diff --git a/tests/integration/AGENTS.md b/tests/integration/AGENTS.md index 41b9d60..7b463db 100644 --- a/tests/integration/AGENTS.md +++ b/tests/integration/AGENTS.md @@ -61,6 +61,7 @@ tests/integration/cases/system// Examples: - `tests/integration/cases/system/devops/` +- `tests/integration/cases/system/monitor/` — profile cases use synthetic `fixtures/monitor-profiles.yaml`; never real credentials or live IDs; direct-route cases must assert the individual HTTP path, and preflight cases must not execute business queries - `tests/integration/cases/system/apigateway/` If you add a new public system command to `cmd/system//`, add @@ -83,6 +84,7 @@ Recommended filename pattern: Examples: - `SYSGO-001-devops-start-build.yaml` +- `MONITOR-001-monitor-log-search-logs-scene.yaml` - `SYSYAML-001-apigateway-list-gateways.yaml` ## How To Add Coverage For A New System diff --git a/tests/integration/cases/system/monitor/MONITOR-001-monitor-log-search-logs-scene.yaml b/tests/integration/cases/system/monitor/MONITOR-001-monitor-log-search-logs-scene.yaml new file mode 100644 index 0000000..202c3de --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-001-monitor-log-search-logs-scene.yaml @@ -0,0 +1,56 @@ +id: MONITOR-001 +name: monitor log search_logs builds scene arguments +tags: + - coverage-system_go + - suite-integration +steps: + - name: init mock-backed context + command: + - context + - init + - --bk_api_url_tmpl + - ${MOCK_API_BASE_URL}/{gateway_name} + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - name: login + command: + - auth + - login + - --access_token + - integration-token + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - name: search logs by scene + command: + - monitor + - log + - search_logs + - --bk_biz_id + - "100100" + - --scene + - host + - --where + - serverIp=192.0.2.10 + - --query_string + - ERROR + - --since + - 15m + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - path: data.path + contains: /bk-monitor/prod/mcp/search_logs/ + - path: data.tool_args.target_type + equals: scene + - path: data.tool_args.table_id_conditions.0.0.value.0 + equals: host + - path: data.tool_args.table_id_conditions.0.1.field_name + equals: serverIp diff --git a/tests/integration/cases/system/monitor/MONITOR-002-monitor-alert-list-alerts-body-merge.yaml b/tests/integration/cases/system/monitor/MONITOR-002-monitor-alert-list-alerts-body-merge.yaml new file mode 100644 index 0000000..ae0e213 --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-002-monitor-alert-list-alerts-body-merge.yaml @@ -0,0 +1,54 @@ +id: MONITOR-002 +name: monitor alert list_alerts merges body and flags +tags: + - coverage-system_go + - suite-integration +steps: + - name: init mock-backed context + command: + - context + - init + - --bk_api_url_tmpl + - ${MOCK_API_BASE_URL}/{gateway_name} + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - name: login + command: + - auth + - login + - --access_token + - integration-token + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - name: list alerts + command: + - monitor + - alert + - list_alerts + - --body + - '{"bk_biz_id":"1","page_size":"50"}' + - --bk_biz_id + - "100100" + - --status + - ABNORMAL + - --since + - 1h + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - path: data.tool_args.bk_biz_id + equals: "100100" + - path: data.tool_args.bk_biz_ids.0 + equals: "100100" + - path: data.tool_args.page_size + equals: "50" + - path: data.tool_args.status.0 + equals: ABNORMAL diff --git a/tests/integration/cases/system/monitor/MONITOR-003-monitor-log-search-logs-needs-resource.yaml b/tests/integration/cases/system/monitor/MONITOR-003-monitor-log-search-logs-needs-resource.yaml new file mode 100644 index 0000000..f0bb886 --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-003-monitor-log-search-logs-needs-resource.yaml @@ -0,0 +1,46 @@ +id: MONITOR-003 +name: monitor log search_logs explains a missing target +tags: + - coverage-system_go + - suite-integration +steps: + - name: init mock-backed context + command: + - context + - init + - --bk_api_url_tmpl + - ${MOCK_API_BASE_URL}/{gateway_name} + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - name: login + command: + - auth + - login + - --access_token + - integration-token + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - name: search logs without a target + command: + - monitor + - log + - search_logs + - --bk_biz_id + - "100100" + - --since + - 15m + expect: + exit_code: 1 + checks: + - path: ok + equals: false + - path: error.code + equals: needs_resource + - path: data.next.0 + equals: bk-cli monitor log list_log_scenes --bk_biz_id 100100 diff --git a/tests/integration/cases/system/monitor/MONITOR-004-monitor-permission-apply-missing.yaml b/tests/integration/cases/system/monitor/MONITOR-004-monitor-permission-apply-missing.yaml new file mode 100644 index 0000000..3a46e93 --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-004-monitor-permission-apply-missing.yaml @@ -0,0 +1,52 @@ +id: MONITOR-004 +name: monitor permission apply returns either apply link +tags: + - coverage-system_go + - suite-integration +steps: + - name: init mock-backed context + command: + - context + - init + - --bk_api_url_tmpl + - ${MOCK_API_BASE_URL}/{gateway_name} + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - name: login + command: + - auth + - login + - --access_token + - integration-token + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - name: apply for a denied tool + command: + - monitor + - permission + - apply + - --bk_biz_id + - "100100" + - --tool + - list_alerts + expect: + exit_code: 0 + checks: + - path: ok + equals: true + - path: data.decision + equals: apply_required + - path: data.applications.0.any_of.0.action_id + equals: using_alarm_mcp + - path: data.applications.0.any_of.0.apply_url + equals: https://iam.example.com/apply/mcp + - path: data.applications.0.any_of.1.action_id + equals: view_event_v2 + - path: data.applications.0.any_of.1.apply_url + equals: https://iam.example.com/apply/native diff --git a/tests/integration/cases/system/monitor/MONITOR-005-monitor-profiles.yaml b/tests/integration/cases/system/monitor/MONITOR-005-monitor-profiles.yaml new file mode 100644 index 0000000..5406665 --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-005-monitor-profiles.yaml @@ -0,0 +1,76 @@ +id: MONITOR-005 +name: SRE profile discovery, query, permissions and diagnostics +tags: [coverage-system_go, suite-integration] +steps: + - name: offline profile discovery before login + command: [monitor, profile, list, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml] + expect: + exit_code: 0 + checks: + - {path: data.profiles.0.name, equals: ns-a} + - name: offline resolved profile + command: [monitor, profile, show, --profile, worker, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml] + expect: + exit_code: 0 + checks: + - {path: data.log.index_set_id, equals: 101003} + - name: init + command: [context, init, --bk_api_url_tmpl, '${MOCK_API_BASE_URL}/{gateway_name}'] + expect: + exit_code: 0 + - name: login + command: [auth, login, --access_token, integration-token] + expect: + exit_code: 0 + - name: query fixed index without listing indices + command: [monitor, log, search_logs, --profile, worker, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml, --since, 15m, --limit, '1'] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.index_set_id, equals: 101003} + - {path: data.tool_args.bk_biz_id, equals: '100100'} + - {path: profile.name, equals: worker} + - {path: profile.context, equals: default} + - name: scope same business by namespace + command: [monitor, log, search_logs, --profile, ns-a, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml, --since, 15m] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.target_type, equals: scene} + - {path: data.tool_args.table_id_conditions.0.1.value.0, equals: test-cluster} + - {path: data.tool_args.query_string, equals: '__ext.io_kubernetes_pod_namespace:"ns-a"'} + - name: explicit flag wins over body and profile + command: [monitor, log, get_index_set_fields, --profile, worker, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml, --body, '{"index_set_id":111}', --index_set_id, '222'] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.index_set_id, equals: 222} + - name: permission check reuses profile scope + command: [monitor, permission, check, --profile, worker, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml, --tool, get_index_set_fields] + expect: + exit_code: 0 + checks: + - {path: data.decision, equals: ready} + - {path: data.steps.2.resource_context.index_set_id, equals: 101003} + - name: profile preflight reads metadata but no log payload + command: [monitor, profile, check, --profile, worker, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml] + expect: + exit_code: 0 + checks: + - {path: data.checks.0.metadata_tool, equals: get_index_set_fields} + - {path: data.checks.1.metadata_tool, equals: list_time_series_metrics} + - name: status online + command: [monitor, status, --profile, worker, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml] + expect: + exit_code: 0 + checks: + - {path: data.ok, equals: true} + - {path: data.checks.3.name, equals: authenticated_api} + - {path: data.checks.3.status, equals: pass} + - name: status offline explicitly skips authentication + command: [monitor, status, --offline, --check_update] + expect: + exit_code: 0 + checks: + - {path: data.checks.3.status, equals: skipped} + - {path: data.checks.4.status, equals: skipped} diff --git a/tests/integration/cases/system/monitor/MONITOR-006-monitor-profile-guards.yaml b/tests/integration/cases/system/monitor/MONITOR-006-monitor-profile-guards.yaml new file mode 100644 index 0000000..0a6c9aa --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-006-monitor-profile-guards.yaml @@ -0,0 +1,32 @@ +id: MONITOR-006 +name: Profile scope conflicts fail closed +tags: [coverage-system_go, suite-integration] +steps: + - name: reject unknown profile without trying login + command: [monitor, profile, show, --profile, typo, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: profile_not_found} + - name: reject context conflict before context resolution + command: [monitor, log, get_index_set_fields, --context, other, --profile, worker, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: profile_context_mismatch} + - name: reject business conflict before context resolution + command: [monitor, log, get_index_set_fields, --bk_biz_id, '200200', --profile, worker, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: profile_business_mismatch} + - name: init + command: [context, init, --bk_api_url_tmpl, '${MOCK_API_BASE_URL}/{gateway_name}'] + expect: + exit_code: 0 + - name: reject mixed log modes without credentials + command: [monitor, log, search_logs, --profile, worker, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml, --scene, host, --since, 15m] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: profile_log_mode_mismatch} diff --git a/tests/integration/cases/system/monitor/MONITOR-007-monitor-reliability.yaml b/tests/integration/cases/system/monitor/MONITOR-007-monitor-reliability.yaml new file mode 100644 index 0000000..7ecdd1a --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-007-monitor-reliability.yaml @@ -0,0 +1,42 @@ +id: MONITOR-007 +name: Strict inputs, trustworthy anchors and scoped permission responses +tags: [coverage-system_go, suite-integration] +steps: + - name: init + command: [context, init, --bk_api_url_tmpl, '${MOCK_API_BASE_URL}/{gateway_name}'] + expect: + exit_code: 0 + - name: login + command: [auth, login, --access_token, integration-token] + expect: + exit_code: 0 + - name: reject trailing input + command: [monitor, alert, list_alerts, --body, '{"bk_biz_id":"100100"} {}', --since, 15m] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: invalid_body} + - name: reject a mock record inside a success envelope + command: [monitor, log, search_index_set_context, --bk_biz_id, '100100', --from, /workspace/tests/integration/fixtures/monitor-anchor-mock.json, --dry-run] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: mock_record} + - name: reject an anchor copied from another context + command: [monitor, log, search_index_set_context, --bk_biz_id, '100100', --from, /workspace/tests/integration/fixtures/monitor-anchor-foreign.json, --dry-run] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: anchor_context_mismatch} + - name: fail closed on a mismatched permission scope + command: [monitor, permission, check, --bk_biz_id, '900007', --tool, get_index_set_fields, --body, '{"index_set_id":101003}'] + expect: + exit_code: 2 + checks: + - {path: error.code, equals: permission_response_invalid} + - name: permission list supplies a concrete scoped next command + command: [monitor, permission, list, --bk_biz_id, '100100', --module, alert] + expect: + exit_code: 0 + checks: + - {path: data.next.0, equals: 'bk-cli monitor permission apply --bk_biz_id 100100 --tool list_alerts --context default'} diff --git a/tests/integration/cases/system/monitor/MONITOR-008-monitor-log-filters-relations.yaml b/tests/integration/cases/system/monitor/MONITOR-008-monitor-log-filters-relations.yaml new file mode 100644 index 0000000..90df67e --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-008-monitor-log-filters-relations.yaml @@ -0,0 +1,44 @@ +id: MONITOR-008 +name: Log row filters and relation batch outcome checks +tags: [coverage-system_go, suite-integration] +steps: + - name: init + command: [context, init, --bk_api_url_tmpl, '${MOCK_API_BASE_URL}/{gateway_name}'] + expect: + exit_code: 0 + - name: login + command: [auth, login, --access_token, integration-token] + expect: + exit_code: 0 + - name: filter rows without changing table routing + command: [monitor, log, search_logs, --bk_biz_id, '100100', --scene, k8s, --where, cluster_id=test-cluster, --filter, namespace=ns-a, --query_string, 'ERROR OR WARN', --since, 15m] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.query_string, equals: '(ERROR OR WARN) AND (namespace:"ns-a")'} + - {path: data.tool_args.table_id_conditions.0.1.field_name, equals: cluster_id} + - name: explicitly override the profile namespace row filter + command: [monitor, log, search_logs, --profile, ns-a, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml, --filter, __ext.io_kubernetes_pod_namespace=ns-b, --since, 15m] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.query_string, equals: '__ext.io_kubernetes_pod_namespace:"ns-b"'} + - {path: data.tool_args.table_id_conditions.0.1.field_name, equals: cluster_id} + - name: reject a malformed filter before calling a backend + command: [monitor, log, search_logs, --bk_biz_id, '100100', --index_set_id, '101003', --filter, 'field) OR *=x', --since, 15m] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: invalid_log_filter} + - name: a successful empty relation is still a successful query + command: [monitor, relation, find_relations, --bk_biz_id, '100100', --query_list, '[{"timestamp":1,"source_info":{}}]'] + expect: + exit_code: 0 + checks: + - {path: data.data.0.code, equals: 200} + - name: a failed relation item must not be hidden by HTTP success + command: [monitor, relation, find_relations, --bk_biz_id, '900008', --query_list, '[{"timestamp":1,"source_info":{}}]'] + expect: + exit_code: 2 + checks: + - {path: error.code, equals: relation_query_failed} diff --git a/tests/integration/cases/system/monitor/MONITOR-009-monitor-heterogeneous-profiles.yaml b/tests/integration/cases/system/monitor/MONITOR-009-monitor-heterogeneous-profiles.yaml new file mode 100644 index 0000000..e7f75af --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-009-monitor-heterogeneous-profiles.yaml @@ -0,0 +1,98 @@ +id: MONITOR-009 +name: V2 components select independent evidence sources and named native presets +tags: [coverage-system_go, suite-integration] +steps: + - name: list business components without any context + command: [monitor, profile, list, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles-v2.yaml] + expect: + exit_code: 0 + checks: + - {path: data.profiles.0.components.0, equals: backend} + - name: preview preflight plan without credentials + command: [monitor, profile, check, --profile, game-prod, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles-v2.yaml, --dry-run] + expect: + exit_code: 0 + checks: + - {path: dry_run, equals: true} + - {path: data.network, equals: not_used} + - name: require an explicit component + command: [monitor, log, search_logs, --profile, game-prod, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles-v2.yaml, --since, 15m] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: needs_component} + - name: init log source + command: [context, init, --bk_api_url_tmpl, '${MOCK_API_BASE_URL}/{gateway_name}'] + expect: + exit_code: 0 + - name: login log source + command: [auth, login, --access_token, integration-log-token] + expect: + exit_code: 0 + - name: create trace source on another endpoint + command: [context, create, cloud, --bk_api_url_tmpl, '${MOCK_API_BASE_URL}/alternate/{gateway_name}'] + expect: + exit_code: 0 + - name: login trace source + command: [auth, login, --context, cloud, --access_token, integration-trace-token] + expect: + exit_code: 0 + - name: worker log source + command: [monitor, log, search_logs, --profile, game-prod, --component, worker, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles-v2.yaml, --since, 15m] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.index_set_id, equals: 1502} + - {path: data.tool_args.bk_biz_id, equals: '10007'} + - {path: profile.context, equals: default} + - {path: profile.binding, equals: logs} + - name: backend trace source + command: [monitor, apm, search_spans, --profile, game-prod, --component, backend, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles-v2.yaml, --since, 15m] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.bk_biz_id, equals: '10008'} + - {path: data.request_path, equals: /alternate/bk-monitor/prod/mcp/search_spans/} + - {path: profile.context, equals: cloud} + - {path: data.tool_args.filters.0.value.0, equals: api} + - name: reject ambiguous golden query + command: [monitor, metric, execute_range_query, --profile, game-prod, --component, backend, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles-v2.yaml, --since, 15m] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: needs_binding} + - name: select one golden query + command: [monitor, metric, execute_range_query, --profile, game-prod, --component, backend, --binding, traffic, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles-v2.yaml, --since, 15m] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.promql, equals: 'sum(request_qps)'} + - {path: profile.context, equals: cloud} + - name: apply exact policy preset + command: [monitor, alarm, get_alarm_strategy, --profile, game-prod, --component, backend, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles-v2.yaml] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.conditions.0.value.0, equals: '1701'} + - name: preflight every binding + command: [monitor, profile, check, --profile, game-prod, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles-v2.yaml] + expect: + exit_code: 0 + checks: + - {path: data.ok, equals: true} + - {path: data.bindings.0.status, equals: pass} + - name: diagnose both source contexts + command: [monitor, status, --profile, game-prod, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles-v2.yaml] + expect: + exit_code: 0 + checks: + - {path: data.sources.logs.context, equals: default} + - {path: data.sources.traces.context, equals: cloud} + - {path: data.sources.logs.authentication, equals: verified} + - {path: data.sources.traces.authentication, equals: verified} + - name: selected trace source must not replace active context + command: [context, status] + expect: + exit_code: 0 + checks: + - {path: data.context, equals: default} diff --git a/tests/integration/cases/system/monitor/MONITOR-010-monitor-time-inputs.yaml b/tests/integration/cases/system/monitor/MONITOR-010-monitor-time-inputs.yaml new file mode 100644 index 0000000..85ad134 --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-010-monitor-time-inputs.yaml @@ -0,0 +1,62 @@ +id: MONITOR-010 +name: Calendar inputs, timezone and native timestamp contracts +tags: [coverage-system_go, suite-integration] +steps: + - name: init + command: [context, init, --bk_api_url_tmpl, '${MOCK_API_BASE_URL}/{gateway_name}'] + expect: {exit_code: 0} + - name: login + command: [auth, login, --access_token, integration-token] + expect: {exit_code: 0} + - name: readable log time defaults to Asia Shanghai + command: [monitor, log, search_logs, --bk_biz_id, '100100', --index_set_id, '101003', --start_time, '2026-10-13 14:00:00', --end_time, '2026-10-13 15:00:00'] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.start_time, equals: '1791871200'} + - {path: data.tool_args.end_time, equals: '1791874800'} + - name: explicit RFC3339 offset wins over timezone flag + command: [monitor, log, search_logs, --bk_biz_id, '100100', --index_set_id, '101003', --start_time, '2026-10-13T14:00:00+08:00', --end_time, '2026-10-13T07:00:00Z', --timezone, UTC] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.start_time, equals: '1791871200'} + - {path: data.tool_args.end_time, equals: '1791874800'} + - name: explicit seconds converted to SQL milliseconds + command: [monitor, metric, execute_sql_query, --bk_biz_id, '100100', --table_id, example.table, --sql, 'SELECT 1', --start_time, 1791871200s, --end_time, 1791874800s] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.start_time, equals: '1791871200000'} + - {path: data.tool_args.end_time, equals: '1791874800000'} + - name: Profiling receives integers rather than strings + command: [monitor, apm, get_profile_type, --bk_biz_id, '100100', --app_name, example-app, --service_name, example-service, --start_time, '2026-10-13 14:00', --end_time, '2026-10-13 15:00'] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.start_time, equals: 1791871200} + - {path: data.tool_args.end_time, equals: 1791874800} + - name: body calendar times are normalized too + command: [monitor, log, search_logs, --body, '{"bk_biz_id":"100100","index_set_id":101003,"start_time":"2026-10-13 14:00","end_time":"2026-10-13 15:00"}'] + expect: + exit_code: 0 + checks: + - {path: data.tool_args.start_time, equals: '1791871200'} + - name: relative window cannot silently override body times + command: [monitor, log, search_logs, --body, '{"bk_biz_id":"100100","index_set_id":101003,"start_time":"yesterday","end_time":"today"}', --since, 1h] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: conflicting_time} + - name: timezone ambiguity is rejected + command: [monitor, log, search_logs, --bk_biz_id, '100100', --index_set_id, '101003', --start_time, '2025-11-02 01:30:00', --end_time, '2025-11-02 03:00:00', --timezone, America/New_York] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: ambiguous_local_time} + - name: no reverse time interval + command: [monitor, log, search_logs, --bk_biz_id, '100100', --index_set_id, '101003', --start_time, '2026-10-13 15:00', --end_time, '2026-10-13 14:00'] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: invalid_time_range} diff --git a/tests/integration/cases/system/monitor/MONITOR-011-monitor-direct-options-jq.yaml b/tests/integration/cases/system/monitor/MONITOR-011-monitor-direct-options-jq.yaml new file mode 100644 index 0000000..093afa1 --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-011-monitor-direct-options-jq.yaml @@ -0,0 +1,25 @@ +id: MONITOR-011 +name: monitor direct HTTP actions preserve common request options and jq +steps: + - name: init mock context + command: [context, init, --bk_api_url_tmpl, "${MOCK_API_BASE_URL}/{gateway_name}"] + expect: {exit_code: 0} + - name: login + command: [auth, login, --access_token, integration-token] + expect: {exit_code: 0} + - name: direct log query with common options and projection + command: [monitor, log, search_logs, --bk_biz_id, '100100', --index_set_id, '101003', --since, 15m, --stage, testing, --header, 'X-Example:probe', --header, 'X-Bk-Tenant-Id:tenant-test', --jq, '{ok:.ok,path:.data.path,headers:.data.headers}'] + expect: + exit_code: 0 + checks: + - {path: ok, equals: true} + - {path: path, equals: /bk-monitor/testing/mcp/search_logs/} + - {path: headers.X-Example, equals: probe} + - {path: headers.X-Bk-Tenant-Id, equals: tenant-test} + - name: native GET request + command: [monitor, log, get_index_set_fields, --bk_biz_id, '100100', --index_set_id, '101003', --jq, '.data'] + expect: + exit_code: 0 + checks: + - {path: path, equals: /bk-monitor/prod/mcp/get_index_set_fields/} + - {path: tool_args.index_set_id, equals: 101003} diff --git a/tests/integration/cases/system/monitor/MONITOR-012-monitor-profile-target-override.yaml b/tests/integration/cases/system/monitor/MONITOR-012-monitor-profile-target-override.yaml new file mode 100644 index 0000000..df7363b --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-012-monitor-profile-target-override.yaml @@ -0,0 +1,16 @@ +id: MONITOR-012 +name: monitor profile preflight checks the tool-specific table and propagates request options +steps: + - name: init mock context + command: [context, init, --bk_api_url_tmpl, "${MOCK_API_BASE_URL}/{gateway_name}"] + expect: {exit_code: 0} + - name: login + command: [auth, login, --access_token, integration-token] + expect: {exit_code: 0} + - name: reject the missing overridden table without executing SQL + command: [monitor, profile, check, --profile, sample, --profile_file, /workspace/tests/integration/fixtures/monitor-review-profile.yaml, --stage, testing, --header, 'X-Example:preflight', --header, 'X-Mock-Scenario:MONITOR-012'] + expect: + exit_code: 1 + checks: + - {path: ok, equals: false} + - {path: data.bindings.0.failure.decision, equals: resource_not_found} diff --git a/tests/integration/cases/system/monitor/MONITOR-013-monitor-alert-business-scope.yaml b/tests/integration/cases/system/monitor/MONITOR-013-monitor-alert-business-scope.yaml new file mode 100644 index 0000000..dbd98ac --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-013-monitor-alert-business-scope.yaml @@ -0,0 +1,53 @@ +id: MONITOR-013 +name: native alert search derives one business and keeps list-detail scope +steps: + - name: init context + command: [context, init, --bk_api_url_tmpl, "${MOCK_API_BASE_URL}/{gateway_name}"] + expect: {exit_code: 0} + - name: login + command: [auth, login, --access_token, integration-token] + expect: {exit_code: 0} + - name: derive scope after named override + command: [monitor, alert, list_alerts, --body, '{"bk_biz_id":"900001"}', --bk_biz_id, '100100', --since, 1h, --header, 'X-Mock-Scenario:MONITOR-013'] + expect: + exit_code: 0 + checks: + - {path: data.alerts.0.id, equals: sample-alert} + - {path: data.alerts.0.bk_biz_id, equals: '100100'} + - name: read detail in the same business + command: [monitor, alert, get_alert_info, --bk_biz_id, '100100', --id, sample-alert, --header, 'X-Mock-Scenario:MONITOR-013'] + expect: + exit_code: 0 + checks: + - {path: data.bk_biz_id, equals: '100100'} + - {path: data.id, equals: sample-alert} + - name: derive scope from profile + command: [monitor, alert, list_alerts, --profile_file, /workspace/tests/integration/fixtures/monitor-profiles.yaml, --profile, worker, --since, 1h, --header, 'X-Mock-Scenario:MONITOR-013'] + expect: + exit_code: 0 + checks: + - {path: data.alerts.0.bk_biz_id, equals: '100100'} + - name: derive topn scope + command: [monitor, alert, get_alert_top_n, --bk_biz_id, '100100', --fields, severity, --since, 1h, --header, 'X-Mock-Scenario:MONITOR-013'] + expect: + exit_code: 0 + checks: + - {path: data.scope.0, equals: '100100'} + - name: reject conflicting raw business array before sending + command: [monitor, alert, list_alerts, --body, '{"bk_biz_id":"100100","bk_biz_ids":["100200"]}', --since, 1h, --header, 'X-Mock-Scenario:MONITOR-013'] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: invalid_arguments} + - name: reject an upstream list outside the requested business + command: [monitor, alert, list_alerts, --bk_biz_id, '100100', --since, 1h, --header, 'X-Example:foreign-scope', --header, 'X-Mock-Scenario:MONITOR-013'] + expect: + exit_code: 2 + checks: + - {path: error.code, equals: response_scope_mismatch} + - name: classify native missing alert + command: [monitor, alert, get_alert_info, --bk_biz_id, '100100', --id, missing-alert, --header, 'X-Mock-Scenario:MONITOR-013'] + expect: + exit_code: 1 + checks: + - {path: error.code, equals: resource_not_found} diff --git a/tests/integration/cases/system/monitor/MONITOR-014-monitor-legacy-sql-permission.yaml b/tests/integration/cases/system/monitor/MONITOR-014-monitor-legacy-sql-permission.yaml new file mode 100644 index 0000000..0f39231 --- /dev/null +++ b/tests/integration/cases/system/monitor/MONITOR-014-monitor-legacy-sql-permission.yaml @@ -0,0 +1,19 @@ +id: MONITOR-014 +name: Accept an explicit legacy SQL permission for the exact business +tags: [coverage-system_go, suite-integration] +steps: + - name: init + command: [context, init, --bk_api_url_tmpl, '${MOCK_API_BASE_URL}/{gateway_name}'] + expect: + exit_code: 0 + - name: login + command: [auth, login, --access_token, integration-token] + expect: + exit_code: 0 + - name: check legacy space permission without executing SQL + command: [monitor, permission, check, --bk_biz_id, '100100', --tool, execute_sql_query] + expect: + exit_code: 0 + checks: + - {path: data.decision, equals: ready} + - {path: data.steps.2.authorization_source, equals: legacy_mcp} diff --git a/tests/integration/fixtures/monitor-anchor-foreign.json b/tests/integration/fixtures/monitor-anchor-foreign.json new file mode 100644 index 0000000..e23582d --- /dev/null +++ b/tests/integration/fixtures/monitor-anchor-foreign.json @@ -0,0 +1 @@ +{"ok":true,"profile":{"context":"other","bk_biz_id":"100100"},"data":{"list":[{"__index_set_id__":101003,"dtEventTimeStamp":"1","serverIp":"synthetic","gseIndex":"2"}]}} diff --git a/tests/integration/fixtures/monitor-anchor-mock.json b/tests/integration/fixtures/monitor-anchor-mock.json new file mode 100644 index 0000000..20bc940 --- /dev/null +++ b/tests/integration/fixtures/monitor-anchor-mock.json @@ -0,0 +1 @@ +{"ok":true,"data":{"list":[{"__demo_mock__":true,"__index_set_id__":101003,"dtEventTimeStamp":"1","serverIp":"synthetic","gseIndex":"2"}]}} diff --git a/tests/integration/fixtures/monitor-profiles-v2.yaml b/tests/integration/fixtures/monitor-profiles-v2.yaml new file mode 100644 index 0000000..f405199 --- /dev/null +++ b/tests/integration/fixtures/monitor-profiles-v2.yaml @@ -0,0 +1,40 @@ +# Synthetic resources and local contexts only. Not production configuration. +version: 2 +sources: + logs: {context: default, bk_biz_id: "10007"} + traces: {context: cloud, bk_biz_id: "10008"} +profiles: + game-prod: + environment: production + components: + backend: + bindings: + logs: {module: log, source: logs, args: {index_set_id: 1501}} + traces: &traces + module: apm + source: traces + args: {app_name: example-production} + tools: + search_spans: + filters: [{key: resource.service.name, operator: equal, value: [api]}] + traffic: + module: metric + source: traces + args: {table_id: example.metrics} + tools: + execute_range_query: {promql: 'sum(request_qps)'} + latency: + module: metric + source: traces + tools: + execute_range_query: {promql: 'max(request_latency)'} + policy: + module: alarm + source: logs + tools: + get_alarm_strategy: + conditions: [{key: strategy_id, value: ["1701"]}] + worker: + bindings: + logs: {module: log, source: logs, args: {index_set_id: 1502}} + traces: *traces diff --git a/tests/integration/fixtures/monitor-profiles.yaml b/tests/integration/fixtures/monitor-profiles.yaml new file mode 100644 index 0000000..44b09f3 --- /dev/null +++ b/tests/integration/fixtures/monitor-profiles.yaml @@ -0,0 +1,24 @@ +# Synthetic data for the isolated integration harness, not a real deployment. +version: 1 +profiles: + worker: &worker + context: default + bk_biz_id: "100100" + log: {index_set_id: 101003} + metric: {table_id: example.table} + ns-a: + <<: *worker + log: + scene: k8s + where: + cluster_id: test-cluster + filters: + __ext.io_kubernetes_pod_namespace: ns-a + ns-b: + <<: *worker + log: + scene: k8s + where: + cluster_id: test-cluster + filters: + __ext.io_kubernetes_pod_namespace: ns-b diff --git a/tests/integration/fixtures/monitor-review-profile.yaml b/tests/integration/fixtures/monitor-review-profile.yaml new file mode 100644 index 0000000..ed746ce --- /dev/null +++ b/tests/integration/fixtures/monitor-review-profile.yaml @@ -0,0 +1,14 @@ +# Synthetic resources only; SQL is a preset inspected during metadata preflight. +version: 2 +sources: {sample-source: {context: default, bk_biz_id: "10007"}} +profiles: + sample: + components: + api: + bindings: + metrics: + module: metric + source: sample-source + args: {table_id: existing.table} + tools: + execute_sql_query: {table_id: missing.table, sql: 'SELECT 1'} diff --git a/tests/integration/mock_api/app.py b/tests/integration/mock_api/app.py index 40dba32..7adb8b9 100644 --- a/tests/integration/mock_api/app.py +++ b/tests/integration/mock_api/app.py @@ -16,6 +16,8 @@ from __future__ import annotations +import json + from typing import Any from flask import Flask, jsonify, request @@ -230,11 +232,142 @@ def start_build(project_id: str): "///", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], ) + @app.route("/alternate///", methods=["GET", "POST"]) def generic_gateway(gateway: str, stage: str, tail: str): negative = maybe_negative_response(gateway, stage, tail) if negative is not None: return negative + if gateway == "bk-monitor" and active_scenario() == "MONITOR-012": + if stage != "testing" or request.headers.get("X-Example") != "preflight": + return jsonify({"result": False, "message": "preflight request options missing"}), 403 + + if gateway == "bk-monitor" and tail == "mcp/lookup_tool/": + payload = request_body() or {} + return jsonify( + { + "result": True, + "data": { + "tools": [ + { + "name": payload.get("tool_name") or "list_alerts", + "risk": "query", + "requires_confirmation": False, + "permission_state": "missing", + } + ] + }, + } + ) + + if gateway == "bk-monitor" and tail == "mcp/lookup_tool_schema/": + if (request_body() or {}).get("tool_name") == "execute_sql_query": + return jsonify({"result": True, "data": {"catalog_version": "integration", "permission": { + "action_id": "using_metrics_mcp", "resource_type": "space", "resource_arg": "bk_biz_id" + }}}) + return jsonify( + { + "result": True, + "data": { + "catalog_version": "integration", + "permission": { + "mode": "mcp_then_native", + "fallback_permission": { + "resource_type": "indices" if (request_body() or {}).get("tool_name") == "get_index_set_fields" else "space", + "resource_arg": "index_set_id" if (request_body() or {}).get("tool_name") == "get_index_set_fields" else "bk_biz_id", + }, + }, + }, + } + ) + + if gateway == "bk-monitor" and tail == "mcp/lookup_permissions/": + payload = request_body() or {} + tool_name = payload.get("tool_name") + if tool_name == "execute_sql_query" and payload.get("bk_biz_id") == "100100": + return jsonify({"result": True, "data": {"authorized": True, "missing_permissions": [], "scopes": [{ + "tool_name": tool_name, "authorized": True, "action_id": "using_metrics_mcp", + "resource": {"bk_biz_id": "100100"} + }]}}) + # Two synthetic V2 evidence sources for MONITOR-009. + if payload.get("bk_biz_id") in ("10007", "10008"): + return jsonify({"result": True, "data": {"authorized": True, "scopes": [{"tool_name": tool_name, "state": "granted"}]}}) + # Synthetic scope mismatch for MONITOR-007; never a real business ID. + if payload.get("bk_biz_id") == "900007": + return jsonify({"result": True, "data": {"authorized": True, "scopes": [{"tool_name": "different_tool", "state": "granted"}]}}) + # Fixed synthetic profile resources; list_alerts remains denied for MONITOR-004. + if payload.get("bk_biz_id") == "100100" and ( + tool_name == "list_time_series_metrics" + or (tool_name == "get_index_set_fields" and (payload.get("resource_context") or {}).get("index_set_id") == 101003) + ): + return jsonify({"result": True, "data": {"authorized": True, "scopes": [{"tool_name": tool_name, "state": "granted"}]}}) + return jsonify( + { + "result": True, + "data": { + "authorized": False, + "scopes": [ + { + "tool_name": tool_name, + "state": "missing", + "authorized": False, + "legacy_permission": { + "system_id": "bk_monitorv3", + "action_id": "using_alarm_mcp", + "resource_type": "space", + "apply_url": "https://iam.example.com/apply/mcp", + }, + "fallback_permission": { + "system_id": "bk_monitorv3", + "action_id": "view_event_v2", + "resource_type": "space", + "apply_url": "https://iam.example.com/apply/native", + }, + } + ], + }, + } + ) + + if gateway == "bk-monitor" and tail.startswith("mcp/"): + tool_name = tail.removeprefix("mcp/").strip("/") + if tool_name == "execute_tool": + return jsonify({"result": False, "message": "generic dispatch is not supported"}), 404 + args = request_body() or {} + if request.method == "GET": + args = dict(request.args) + for key, value in list(args.items()): + if key != "bk_biz_id": + try: + args[key] = json.loads(value) + except (TypeError, ValueError): + pass + if active_scenario() == "MONITOR-012" and tool_name == "list_time_series_metrics": + if args.get("table_id") == "missing.table": + return jsonify({"result": False, "message": "table does not exist"}) + if active_scenario() == "MONITOR-013": + if tool_name in ("list_alerts", "get_alert_top_n"): + if args.get("bk_biz_ids") != [str(args.get("bk_biz_id"))]: + return jsonify({"result": False, "message": "single-business array missing"}), 400 + if tool_name == "get_alert_top_n": + return jsonify({"result": True, "data": {"scope": args["bk_biz_ids"]}}) + biz = "100200" if request.headers.get("X-Example") == "foreign-scope" else args["bk_biz_id"] + return jsonify({"result": True, "data": {"alerts": [{"id": "sample-alert", "bk_biz_id": biz}], "total": 1}}) + if tool_name == "get_alert_info": + if args.get("id") == "missing-alert": + return jsonify({"result": False, "code": 3324002, "message": "synthetic alert not found"}) + return jsonify({"result": True, "data": {"id": args["id"], "bk_biz_id": args["bk_biz_id"]}}) + if tool_name in ("find_relations", "find_relations_range"): + code = 400 if args.get("bk_biz_id") == "900008" else 200 + items = [{"code": code, "target_list": []} for _ in args.get("query_list", [])] + return jsonify({"result": True, "data": {"data": items}}) + return jsonify({"result": True, "data": { + "path": f"/{gateway}/{stage}/{tail}", "request_path": request.path, + "tool_args": args, + **({"alerts": []} if tool_name == "list_alerts" else {}), + "headers": {key: request.headers.get(key) for key in ("X-Example", "X-Bk-Tenant-Id")}, + }}) + if gateway == "bk-cmdb" and tail == "api/v3/open/hosts/modules/read": return jsonify( [