Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
go 1.25.1

require (
github.com/flashcatcloud/go-flashduty v0.5.5-0.20260703065853-f90c46dd6441
github.com/flashcatcloud/go-flashduty v0.5.6-0.20260714082243-a201ab7ce700
github.com/mattn/go-runewidth v0.0.24
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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.5.5-0.20260703065853-f90c46dd6441 h1:N84LGiMMOlKG3Euq0Sp9bAhWkohM0XBpe3ebjAe1+SM=
github.com/flashcatcloud/go-flashduty v0.5.5-0.20260703065853-f90c46dd6441/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
github.com/flashcatcloud/go-flashduty v0.5.6-0.20260714082243-a201ab7ce700 h1:VexK6e35agNlIou95FbuJsothM5eRTIdh72feSW0wUU=
github.com/flashcatcloud/go-flashduty v0.5.6-0.20260714082243-a201ab7ce700/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
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.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
Expand Down
12 changes: 3 additions & 9 deletions internal/cli/automation.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,6 @@ func buildAutomationFireCmd(use string) *cobra.Command {
var (
token string
text string
dedupKey string
dataJSON string
)

Expand All @@ -468,12 +467,11 @@ func buildAutomationFireCmd(use string) *cobra.Command {
Long: `Trigger an Automation run through its HTTP POST trigger.

The trigger authenticates with its one-time token, not the account app key. Pass
--token or set FLASHDUTY_AUTOMATION_TRIGGER_TOKEN. Use --dedup-key to make
retries idempotent for the same trigger.`,
--token or set FLASHDUTY_AUTOMATION_TRIGGER_TOKEN.`,
Args: requireExactArg("trigger_id"),
RunE: func(cmd *cobra.Command, args []string) error {
return runCommand(cmd, args, func(ctx *RunContext) error {
out, err := runAutomationFire(ctx, ctx.Args[0], token, text, dedupKey, dataJSON)
out, err := runAutomationFire(ctx, ctx.Args[0], token, text, dataJSON)
if err != nil {
return err
}
Expand All @@ -484,19 +482,15 @@ retries idempotent for the same trigger.`,

cmd.Flags().StringVar(&token, "token", "", "HTTP POST trigger token; defaults to FLASHDUTY_AUTOMATION_TRIGGER_TOKEN")
cmd.Flags().StringVar(&text, "text", "", "Context text passed to this run")
cmd.Flags().StringVar(&dedupKey, "dedup-key", "", "Optional idempotency key")
cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; typed flags override its fields. Accepts inline JSON, or - to read stdin.")
return cmd
}

func runAutomationFire(ctx *RunContext, triggerID, token, text, dedupKey, dataJSON string) (*flashduty.AutomationFireAPITriggerResponse, error) {
func runAutomationFire(ctx *RunContext, triggerID, token, text, dataJSON string) (*flashduty.AutomationFireAPITriggerResponse, error) {
body, err := genAssembleBody(dataJSON, func(body map[string]any) error {
if ctx.Cmd.Flags().Changed("text") {
body["text"] = text
}
if ctx.Cmd.Flags().Changed("dedup-key") {
body["dedup_key"] = dedupKey
}
return nil
})
if err != nil {
Expand Down
23 changes: 17 additions & 6 deletions internal/cli/automation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,17 +124,15 @@ func TestAutomationFireSendsBearerToken(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = map[string]any{
"rule_id": "auto_123",
"run_id": "run_123",
"status": "queued",
"trigger_kind": "http_post",
"type": "routine_fire",
"session_id": "sess_123",
"session_url": "/safari/session/sess_123",
}

_, err := execCommand(
"automation", "fire", "auttrig_123",
"--token", "token-123",
"--text", "manual test",
"--dedup-key", "once",
"--json",
)
if err != nil {
Expand All @@ -147,7 +145,20 @@ func TestAutomationFireSendsBearerToken(t *testing.T) {
t.Fatalf("[automation-fire] authorization = %q", stub.lastAuthorization)
}
assertBody(t, stub.lastBody, "text", "manual test")
assertBody(t, stub.lastBody, "dedup_key", "once")
}

func TestAutomationFireDoesNotExposeIgnoredDedupKey(t *testing.T) {
saveAndResetGlobals(t)
newGFStub(t)

_, err := execCommand(
"automation", "fire", "auttrig_123",
"--token", "token-123",
"--dedup-key", "once",
)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --dedup-key") {
t.Fatalf("expected dedup-key to be rejected, got %v", err)
}
}

func TestSafariAutomationTriggerFirePathCommand(t *testing.T) {
Expand Down
26 changes: 0 additions & 26 deletions internal/cli/gen_positional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,32 +185,6 @@ func TestGenPositionalRequiredFieldCanComeFromDataOrFlag(t *testing.T) {
t.Errorf("rule_id from --rule-id = %#v, want auto_2", got)
}

if _, err := execCommand("incident-trigger-subscription", "upsert", "--data", `{"channel_ids":[2468013579],"consumer":"fc_safari","consumer_ref":"auto_1","severities":["Critical"],"source":"ai_sre_automation"}`); err != nil {
t.Fatalf("incident-trigger-subscription upsert --data: %v", err)
}
if stub.lastPath != "/incident-trigger-subscription/upsert" {
t.Fatalf("path = %q, want /incident-trigger-subscription/upsert", stub.lastPath)
}
raw, ok := stub.lastBody["channel_ids"].([]any)
if !ok || len(raw) != 1 || raw[0] != float64(2468013579) {
t.Errorf("channel_ids from --data = %#v, want [2468013579]", stub.lastBody["channel_ids"])
}

if _, err := execCommand("incident-trigger-subscription", "upsert", "--channel-ids", "2468013580", "--consumer", "fc_safari", "--consumer-ref", "auto_2", "--severities", "Warning", "--source", "ai_sre_automation"); err != nil {
t.Fatalf("incident-trigger-subscription upsert --channel-ids: %v", err)
}
raw, ok = stub.lastBody["channel_ids"].([]any)
if !ok || len(raw) != 1 || raw[0] != float64(2468013580) {
t.Errorf("channel_ids from --channel-ids = %#v, want [2468013580]", stub.lastBody["channel_ids"])
}

if _, err := execCommand("incident-trigger-subscription", "upsert", "--channel-ids", "2468013581", "--consumer", "fc_safari", "--consumer-ref", "auto_3", "--severities", "Warning", "--source", "ai_sre_automation", "--enabled=false"); err != nil {
t.Fatalf("incident-trigger-subscription upsert --enabled=false: %v", err)
}
if got, ok := stub.lastBody["enabled"].(bool); !ok || got {
t.Errorf("enabled = %#v, want explicit false", stub.lastBody["enabled"])
}

if _, err := execCommand("safari", "automation-rule-run"); err == nil || !strings.Contains(err.Error(), "missing rule_id") {
t.Fatalf("automation-rule-run without arg/data/flag error = %v, want missing rule_id", err)
}
Expand Down
52 changes: 52 additions & 0 deletions internal/cli/monit_query_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"encoding/json"
"fmt"
"strings"
"testing"
Expand Down Expand Up @@ -75,6 +76,57 @@ func TestMonitQueryDiagnoseHappyPath(t *testing.T) {
}
}

func TestMonitQueryDiagnoseRendersMetricEvidence(t *testing.T) {
saveAndResetGlobals(t)
stub := newGFStub(t)
stub.data = map[string]any{
"schema_version": "2",
"operation": "metric_trends",
"ds_type": "prometheus",
"ds_name": "prod-prometheus",
"query": "up",
"window": map[string]any{"start": "2026-07-14T06:00:00Z", "end": "2026-07-14T07:00:00Z"},
"results": []any{map[string]any{
"method": "window_compare",
"window": map[string]any{"start": "2026-07-14T06:00:00Z", "end": "2026-07-14T07:00:00Z"},
"summary": map[string]any{
"series_total": 1, "series_analyzed": 1, "selected_series_total": 1, "series_returned": 1,
"analysis_truncated": false, "evidence_summary": "One series changed.",
},
"series_evidence": []any{map[string]any{
"labels": map[string]any{"instance": "api-1"},
"observations": []any{"The current average increased."},
}},
"warnings": []any{},
}},
}

out, err := execCommand(
"monit-query", "diagnose",
"--ds-type", "prometheus",
"--ds-name", "prod-prometheus",
"--input-query", "up",
"--operation", "metric_trends",
"--output-format", "json",
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var rendered map[string]any
if err := json.Unmarshal([]byte(out), &rendered); err != nil {
t.Fatalf("decode CLI JSON: %v\n%s", err, out)
}
if _, found := rendered["data_handling"]; found {
t.Fatalf("metric output fabricated data_handling: %s", out)
}
evidence := rendered["results"].([]any)[0].(map[string]any)["series_evidence"].([]any)[0].(map[string]any)
for _, field := range []string{"comparison_status", "current_window_stats", "baseline_window_stats"} {
if _, found := evidence[field]; found {
t.Fatalf("metric evidence fabricated %s: %s", field, out)
}
}
}

func TestMonitQueryDiagnoseRequiredFlags(t *testing.T) {
cases := []struct {
name string
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ var rootCmd = &cobra.Command{
if cmd.CommandPath() == "flashduty update" {
return nil
}
if update.IsManagedByRunner() {
return nil
}
if !isTerminalFn(int(os.Stderr.Fd())) {
return nil
}
Expand Down
42 changes: 42 additions & 0 deletions internal/cli/root_managed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cli

import (
"runtime"
"testing"

"github.com/flashcatcloud/flashduty-cli/internal/update"
)

func TestRootSkipsAutoUpdateWhenManagedByRunner(t *testing.T) {
saveAndResetGlobals(t)
tmp := t.TempDir()
t.Setenv("HOME", tmp)
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", tmp)
}
t.Setenv("CI", "")
t.Setenv("GITHUB_ACTIONS", "")
t.Setenv("JENKINS_URL", "")
t.Setenv("GITLAB_CI", "")
t.Setenv("FLASHDUTY_NO_UPDATE_CHECK", "")
t.Setenv("FLASHDUTY_MANAGED_BY_RUNNER", "1")

origIsTerminal := isTerminalFn
isTerminalFn = func(int) bool { return true }
t.Cleanup(func() { isTerminalFn = origIsTerminal })

called := false
origCheck := checkForUpdateAutoFn
checkForUpdateAutoFn = func(string) (*update.CheckResult, error) {
called = true
return &update.CheckResult{}, nil
}
t.Cleanup(func() { checkForUpdateAutoFn = origCheck })

if _, err := execCommand("version"); err != nil {
t.Fatalf("version command should run in runner-managed mode: %v", err)
}
if called {
t.Fatal("runner-managed CLI must not check for updates")
}
}
4 changes: 4 additions & 0 deletions internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ func newUpdateCmd() *cobra.Command {
Use: "update",
Short: "Update flashduty to the latest version",
RunE: func(cmd *cobra.Command, _ []string) error {
if update.IsManagedByRunner() {
return fmt.Errorf("flashduty is managed by flashduty-runner; upgrade the runner instead")
}

w := cmd.OutOrStdout()
_, _ = fmt.Fprintf(w, "Current version: %s\n", versionStr)
_, _ = fmt.Fprintf(w, "Checking for updates...\n")
Expand Down
29 changes: 29 additions & 0 deletions internal/cli/update_managed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package cli

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestUpdateRejectsRunnerManagedCLIWithoutChecking(t *testing.T) {
saveAndResetGlobals(t)
t.Setenv("FLASHDUTY_MANAGED_BY_RUNNER", "1")

checked := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
checked = true
_, _ = w.Write([]byte("v0.0.0\n"))
}))
t.Cleanup(server.Close)
t.Setenv("FLASHDUTY_UPDATE_BASE_URL", server.URL)

_, err := execCommand("update", "--check")
if err == nil || !strings.Contains(err.Error(), "managed by flashduty-runner") {
t.Fatalf("expected runner-managed update rejection, got %v", err)
}
if checked {
t.Fatal("runner-managed CLI must reject update before checking for releases")
}
}
39 changes: 36 additions & 3 deletions internal/cli/zz_generated_alert_enrichment.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading