diff --git a/go.mod b/go.mod index 0fcad0f..23aa614 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli go 1.26.0 require ( - github.com/flashcatcloud/go-flashduty v0.15.15 + github.com/flashcatcloud/go-flashduty v0.15.16 github.com/mattn/go-runewidth v0.0.30 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 335edc9..ee570a4 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/flashcatcloud/go-flashduty v0.15.15 h1:jMgWfpuXqdyHArSvUtf9ATwFFB7s49hgN7Rv+c9PxAY= -github.com/flashcatcloud/go-flashduty v0.15.15/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= +github.com/flashcatcloud/go-flashduty v0.15.16 h1:h04Z0NGxB4ujlfKliVPD8QhgLgBObeAoRMz9rXJpnes= +github.com/flashcatcloud/go-flashduty v0.15.16/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk= diff --git a/internal/cli/integration_contract_test.go b/internal/cli/integration_contract_test.go new file mode 100644 index 0000000..6c86bfb --- /dev/null +++ b/internal/cli/integration_contract_test.go @@ -0,0 +1,137 @@ +package cli + +import ( + "encoding/json" + "reflect" + "testing" +) + +func integrationContractFixture() map[string]any { + return map[string]any{ + "integration_id": 42, "data_source_id": 42, "account_id": 100, "exclusive_data_source_id": 0, + "name": "Contract fixture", "description": "", "team_id": 7, + "category": "im", "plugin_type": "slack_app", "plugin_type_name": "Slack", "plugin_id": 9, + "ref_id": "a_100", "status": "enabled", "created_at": 1700000000, "updated_at": 1700000001, "last_time": 0, + "creator_id": 11, "updated_by": 12, "no_editable": false, "editable": true, + "settings": map[string]any{"headers": map[string]any{"Authorization": "fixture-not-a-real-secret"}}, + "integration_key": "fixture-not-a-real-key", + } +} + +func TestIntegrationListPreservesConfigurationAndPermissions(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + fixture := integrationContractFixture() + stub.data = map[string]any{"p": 1, "limit": 100, "total": 1, "items": []any{fixture}} + out, err := execCommand("integration", "list", "--json") + if err != nil { + t.Fatal(err) + } + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatal(err) + } + items, ok := result["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("unexpected list output: %s", out) + } + item := items[0].(map[string]any) + for field := range fixture { + if _, ok := item[field]; !ok { + t.Errorf("list dropped backend field %s", field) + } + } + for _, field := range []string{"settings", "integration_key", "editable", "no_editable"} { + if !reflect.DeepEqual(item[field], fixture[field]) { + t.Errorf("%s changed: got %#v, want %#v", field, item[field], fixture[field]) + } + } + if stub.lastPath != "/integration/list" { + t.Fatalf("request path = %s", stub.lastPath) + } +} + +func TestIntegrationInfoPreservesConfigurationAndTestResult(t *testing.T) { + for _, withTest := range []bool{false, true} { + t.Run(map[bool]string{false: "absent test", true: "failed test"}[withTest], func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + fixture := integrationContractFixture() + for _, field := range []string{"account_id", "data_source_id", "exclusive_data_source_id"} { + delete(fixture, field) + } + if withTest { + fixture["test"] = map[string]any{"success": false, "message": "fixture plugin test failed"} + } + stub.data = fixture + out, err := execCommand("integration", "info", "42", "--json") + if err != nil { + t.Fatal(err) + } + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatal(err) + } + for _, field := range []string{"settings", "integration_key", "plugin_id", "editable", "creator_id", "updated_by", "no_editable"} { + if _, ok := result[field]; !ok { + t.Errorf("info dropped backend field %s", field) + } + } + if !reflect.DeepEqual(result["settings"], fixture["settings"]) || result["integration_key"] != fixture["integration_key"] { + t.Errorf("info changed configuration or key: %s", out) + } + if !reflect.DeepEqual(result["test"], fixture["test"]) { + t.Errorf("test result changed: %s", out) + } + if !withTest { + if _, ok := result["test"]; ok { + t.Errorf("absent test was fabricated: %s", out) + } + } + }) + } +} + +func TestIntegrationCreatePreservesKeyAndPluginTest(t *testing.T) { + for _, mode := range []string{"data", "flags"} { + t.Run(mode, func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"integration_id": 42, "integration_key": "fixture-key", "test": map[string]any{"success": false, "message": "fixture plugin test failed"}} + args := []string{"integration", "create", "--json", "--data", `{"plugin_type":"standard.alert","integration_key":"00000000000000000000000000000000100","is_test":true}`} + if mode == "flags" { + args = []string{"integration", "create", "--json", "--plugin-type", "standard.alert", "--integration-key", "00000000000000000000000000000000100", "--is-test"} + } + out, err := execCommand(args...) + if err != nil { + t.Fatal(err) + } + if stub.lastBody["integration_key"] != "00000000000000000000000000000000100" || stub.lastBody["is_test"] != true { + t.Errorf("create dropped key or test flag: %#v", stub.lastBody) + } + var result map[string]any + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(result["test"], stub.data.(map[string]any)["test"]) { + t.Errorf("create lost failed plugin test: %s", out) + } + }) + } +} + +func TestIntegrationUpdatePreservesTestAndExplicitEmptyHeaders(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + _, err := execCommand("integration", "update", "42", "--team-id", "0", "--is-test", "--data", `{"settings":{"headers":{}}}`) + if err != nil { + t.Fatal(err) + } + if stub.lastBody["is_test"] != true || stub.lastBody["team_id"] != float64(0) { + t.Errorf("update lost test flag or explicit team clear: %#v", stub.lastBody) + } + want := map[string]any{"headers": map[string]any{}} + if !reflect.DeepEqual(stub.lastBody["settings"], want) { + t.Errorf("update changed explicit empty headers: %#v", stub.lastBody) + } +} diff --git a/skills/flashduty/SKILL.md b/skills/flashduty/SKILL.md index 5b3c05b..44d7c92 100644 --- a/skills/flashduty/SKILL.md +++ b/skills/flashduty/SKILL.md @@ -1,7 +1,7 @@ --- name: flashduty version: "3.0" -description: "USE FIRST for Flashduty tasks — status pages, incidents, alerts, on-call, monitors, automations, RUM, sourcemaps, members. `flashduty` CLI = the whole API. ALWAYS load this skill + read reference/.md for exact verbs & flags BEFORE running flashduty. Don't guess or --help-dance." +description: "USE FIRST for Flashduty tasks — status pages, incidents, alerts, integrations, on-call, monitors, automations, RUM, sourcemaps, members. `flashduty` CLI = the whole API. ALWAYS load this skill + read reference/.md for exact verbs & flags BEFORE running flashduty. Don't guess or --help-dance." allowed-tools: bash, read hidden: true # internal-only: withheld from skills.sh public discovery (Safari embeds this skill directly). --- @@ -76,7 +76,8 @@ Some asks span several commands. For those the skill ships a script that fetches | datasource 数据源 = a system Flashmonit queries (Prometheus / Loki / SQL / SLS) / connect a datasource 连接数据源 / SLS project / logstore / Redis / MongoDB / Kafka / database overview 数据库概览 / locks 锁 / slowlog 慢日志 | **`reference/monit-datasource.md`** | | automation / 自动化 / 定时 AI SRE / scheduled AI task / daily brief / weekly report / webhook trigger / POST trigger / chat-created automation | **`reference/automation.md`** | | metric/log query / 指标查询 / 日志查询 / PromQL / LogsQL / SQL / trend 趋势 / log clustering 日志聚类 / datasource RCA 数据源排查 | **`reference/monit-query.md`** | -| channel / 协作空间 / collaboration space / 频道 / integration 集成 / 告警来源 alert source / alert grouping 告警分组 | **`reference/channel.md`** | +| public integration / 公共集成 / 集成中心 / integration 集成 / alert source 告警来源 / integration key 集成 Key / IM bot / webhook configuration | **`reference/integration.md`** | +| channel / 协作空间 / collaboration space / 频道 / channel-owned dedicated integration 协作空间专属集成 / alert grouping 告警分组 | **`reference/channel.md`** | | dispatch rule 分派策略 / 分派规则 / escalation rule 升级规则 / notify layers 通知层级 / who gets paged | **`reference/escalation.md`** | | silence 静默 / 屏蔽 / inhibit 抑制 / drop rule 丢弃 / noise reduction 降噪 / maintenance silence 维护窗口静默 | **`reference/noise.md`** | | enrichment / 数据加工 / 富化 / label mapping 字段映射 / extraction 提取 / mapping schema 集成 schema | **`reference/enrichment.md`** | diff --git a/skills/flashduty/reference/channel.md b/skills/flashduty/reference/channel.md index 521a937..8533e53 100644 --- a/skills/flashduty/reference/channel.md +++ b/skills/flashduty/reference/channel.md @@ -133,5 +133,5 @@ Escalation, silence, inhibit and unsubscribe rules live on their own cards: `ref - **`channel-id` can be passed positionally or via `--channel-id` — both work** on every verb of this card (`info`, `infos`, `update`, `delete`, `disable`, `enable`). The fence heading `### verb ` shows the shorter positional form; the flag is an equally valid alternative, and if both are given the flag value wins. - **`channel create` requires `--channel-name` and `--team-id`** even though they are not marked `required` in the flag list — the server rejects the request without them. -- **`--plugin-ids` subscribes integrations that already exist; no `flashduty` verb creates or configures one.** Creating an integration (email, webhook, an alert source) and reading its inbound address/token live in the console only — they are not part of the open API this CLI speaks, so they are absent from the command tree by design. When the user asks how to *connect* something, answer from the product documentation and point at the console; do not dump and grep the command tree looking for a verb that cannot exist. `channel info` likewise reports the channel's own fields, not integration credentials. +- **`--plugin-ids` subscribes integrations that already exist.** Public integration creation, configuration, and inbound keys are available through the `integration` commands; read `reference/integration.md`. Channel-owned dedicated integrations are outside that public API surface and are managed in the console. `channel info` reports the channel's own fields, not integration credentials. - **`delete` on a channel is irreversible** — all rules within it (escalation, silence, inhibit, drop) are also removed. Confirm the `channel-id` against `list` before proceeding. diff --git a/skills/flashduty/reference/integration.md b/skills/flashduty/reference/integration.md new file mode 100644 index 0000000..c46e12d --- /dev/null +++ b/skills/flashduty/reference/integration.md @@ -0,0 +1,107 @@ +# flashduty integration — command card + +Prereq: read `SKILL.md`. These commands manage **public/account-level integrations**, including supported alert, change, IM, and webhook types. They do not manage channel-owned dedicated integrations (`c_` references); use the console for those. For channel configuration and subscriptions, read `reference/channel.md`. + +## Read before changing configuration + +- Start with `type-list` and `list`. Create only a `plugin_type` whose type entry has `supports_api_create: true`. +- List/info return stored `settings` and decrypted `integration_key` without masking. Prefer `--fields` projections for discovery; do not paste secrets into a report or unrelated tool. An integration key authenticates inbound pushes; it is not the account App Key used by this CLI. +- `editable` describes object permission; writes also require Integrations Manage. `no_editable: true` marks a read-only integration. Do not infer write authorization from read access. +- Before update/delete or key rotation, get per-target confirmation and save `info` to the protected workspace backup described in `SKILL.md`. Confirm references before deletion. Rotating the key invalidates the old key immediately; update the sender configuration as part of the approved change. +- Metadata-only updates preserve omitted fields. `--team-id 0` deliberately clears team ownership. Submitted `settings` replace type-specific configuration; known sensitive fields omitted or set to `******` preserve their stored value. An explicit `headers: {}` clears headers. +- `--is-test` on create, or on update with `settings`, returns plugin test results. Inspect `test.success` and `test.message`: HTTP success is not plugin-test success. An absent `test` means no test result was returned, not a successful test. + +## Discovery + +```bash +flashduty integration type-list --category event.alert --output-format toon +flashduty integration list --fields integration_id,name,plugin_type,team_id,editable,no_editable --output-format toon +flashduty integration info 42 --fields integration_id,name,plugin_type,team_id,editable,no_editable --output-format toon +``` + +## Approved configuration changes + +Use the exact type-specific settings provided for the target integration; do not guess credential keys. `--integration-key` is optional on create; omitting it generates a key. If provided, it must satisfy the account suffix, length, and uniqueness checks. + +```bash +# Only after confirmation; inspect the returned integration_id/key and test result. +flashduty integration create --plugin-type standard.alert --name "Production alerts" --is-test --data '{"settings":{}}' --json +# Only after backing up integration 42 and confirming this rename. +flashduty integration update 42 --name "Production alerts (primary)" --json +``` + +Do not add `--is-test` to a metadata-only update expecting a plugin test; settings must be submitted for an update test. Disable/enable preserve configuration. Delete may fail with `ReferenceExist` when other objects still reference the integration. + + + +### create +Create integration +- `--description` string — Free-form description, at most 499 characters. (≤499 chars) +- `--integration-key` string — Optional pre-generated key: 35 characters, ending with the account ID modulo 1000 padded to three digits; it must not already be in use. Omit to generate a key. (35-35 chars) +- `--is-test` bool — Run the plugin test during creation; false when omitted. Inspect the returned 'test.success' for the test outcome. +- `--name` string — Integration name, 2–49 characters when nonempty; defaults to the integration type name when omitted or empty. (2-49 chars) +- `--plugin-type` string (required) — Integration type. Must be one listed by 'POST /integration/type/list' with 'supports_api_create: true'. +- `--team-id` int64 — Owning team ID; omit or use '0' for no team. A nonzero ID must belong to the current account. (min 0) +- body-only (`--data`): settings (object) +- response: single object (`data` unwrapped to the top level) — fields: integration_id (integer); integration_key (string); test (object) + +### delete +Delete integration +- `` (positional, required) int64 — Integration ID. (min 1) + +### disable +Disable integration +- `` (positional, required) int64 — Integration ID. (min 1) + +### enable +Enable integration +- `` (positional, required) int64 — Integration ID. (min 1) + +### info +Get integration detail +- `` (positional, required) int64 — Integration ID. (min 1) +- response: single object (`data` unwrapped to the top level) — fields: category (string); created_at (string); creator_id (integer); description (string); editable (boolean); integration_id (integer); integration_key (string); last_time (string); name (string); no_editable (boolean); plugin_id (integer); plugin_type (string); plugin_type_name (string); ref_id (string); settings (object); status (string); team_id (integer); test (object); updated_at (string); updated_by (integer) + +### key-rotate +Rotate integration key +- `` (positional, required) int64 — Integration ID. (min 1) +- response: single object (`data` unwrapped to the top level) — fields: integration_key (string) + +### list +List integrations +- `--asc` bool — Sort ascending when true, descending when false. +- `--category` string — Filter by category. Accepts a comma-separated list. +- `--is-my-team` bool — Limit the result to integrations owned by your teams. +- `--limit` int64 — Page size. Defaults to 100, maximum 100. (1-100) +- `--name` string — Filter by integration name. +- `--orderby` string — Sort field. Defaults to 'created_at'; 'plugin_type' is sorted by the underlying plugin. · enum: created_at | updated_at | name | plugin_type | status +- `--page` int64 — Page number, 1-based. (min 1) +- `--plugin-type` string — Filter by integration type. Accepts a comma-separated list. +- `--ref-ids` stringSlice — Filter by source reference IDs. Each value must start with 'c_' (channel), 'a_' (account) or 'w_'. +- `--search-after-ctx` string +- `--status` string — Filter by status. Accepts a comma-separated list. +- `--team-ids` intSlice — Filter by team IDs. With 'is_my_team', the values narrow that set further. +- `--type` string — Deprecated. Merged into 'plugin_type' when both are set. +- response: `{items: [...], limit, p, total}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — items fields: account_id (integer); category (string); created_at (string); creator_id (integer); data_source_id (integer); description (string); editable (boolean); exclusive_data_source_id (integer); integration_id (integer); integration_key (string); last_time (string); name (string); no_editable (boolean); plugin_id (integer); plugin_type (string); plugin_type_name (string); ref_id (string); settings (object); status (string); team_id (integer); updated_at (string); updated_by (integer) + +### type-list +List integration types +- `--asc` bool — Sort ascending when 'true' (the default); descending when 'false'. +- `--category` string — Filter by category. Accepts a comma-separated list, for example 'event.alert,event.change'. +- `--limit` int64 — Page size. Defaults to 20, maximum 100. (1-100) +- `--orderby` string — Sort field. When omitted, types are returned in console ranking order. · enum: id | created_at | updated_at | name | type +- `--page` int64 — Page number, 1-based. (min 1) +- `--search-after-ctx` string +- response: `{items: [...], limit, p, total}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — items fields: category (string); plugin_type (string); plugin_type_logo_url (string); plugin_type_name (string); status (string); supports_api_create (boolean) + +### update +Update integration +- `--description` string — New description, at most 499 characters. (≤499 chars) +- `` (positional, required) int64 — Integration ID. (min 1) +- `--is-test` bool — Run the plugin test when submitting 'settings'; no test is performed for a metadata-only update. +- `--name` string — New name, 2–49 characters. (2-49 chars) +- `--team-id` int64 — New owning team ID; '0' clears the team assignment. (min 0) +- body-only (`--data`): settings (object) +- response: same shape as `info ` above + +