Skip to content

Commit 14005db

Browse files
committed
Merge origin/main into feat/automation-schedule-timezone
Point go-flashduty at 26910a1 so --timezone and the incident custom-field map both compile.
2 parents 9f3dbb8 + ff6f7d7 commit 14005db

23 files changed

Lines changed: 1151 additions & 90 deletions

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
33
go 1.26.0
44

55
require (
6-
github.com/flashcatcloud/go-flashduty v0.15.12-0.20261009031927-7a88b4a32908
6+
github.com/flashcatcloud/go-flashduty v0.15.13-0.20261009050530-26910a16529d
77
github.com/mattn/go-runewidth v0.0.30
88
github.com/spf13/cobra v1.10.2
99
github.com/spf13/pflag v1.0.10

‎go.sum‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
22
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
33
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4-
github.com/flashcatcloud/go-flashduty v0.15.10 h1:OfCx6EpF5tv+qSXGPZcEwEjXbo0KvQBOaw3ggWj+6Hs=
5-
github.com/flashcatcloud/go-flashduty v0.15.10/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
6-
github.com/flashcatcloud/go-flashduty v0.15.12-0.20261009031927-7a88b4a32908 h1:6gjBhoBX5PoY0bgFP5EnZw5lV+284HVwHylu3fDN2EI=
7-
github.com/flashcatcloud/go-flashduty v0.15.12-0.20261009031927-7a88b4a32908/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
4+
github.com/flashcatcloud/go-flashduty v0.15.13-0.20261009050530-26910a16529d h1:LU8KL6c3fSBVtevbAyLkzNcEltvtWH3wuqeQcNvKMjI=
5+
github.com/flashcatcloud/go-flashduty v0.15.13-0.20261009050530-26910a16529d/go.mod h1:YpHiTYXR5NXBI/rGRZfUy537XMkhdCkwA8NW1QoRHwk=
86
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
97
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
108
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=

‎internal/cli/alert.go‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ func newAlertCmd() *cobra.Command {
2424
}
2525

2626
func newAlertListCmd() *cobra.Command {
27-
var severity, channel, integration, since, until, fields string
28-
var active, recovered, muted bool
27+
var severity, channel, integration, since, until, fields, alertIDs, alertKeys string
28+
var active, recovered, muted, asc, byUpdatedAt bool
2929
var limit, page int
3030

3131
cmd := &cobra.Command{
3232
Use: "list",
3333
Short: "List alerts",
34-
Long: curatedLong("List alerts within a time window, optionally filtered by severity, channel, active/recovered/muted state. No server-side title/text filter — to search by title, pipe --json to jq: 'select(.title|test(\"pat\";\"i\"))'. In json/toon mode, --fields projects each row to just the named fields (e.g. --fields alert_id,title,alert_severity,created_at) so you get a compact record without piping to jq. --limit max 100; --since/--until window must be < 31 days.", "Alerts", "ReadList"),
34+
Long: curatedLong("List alerts within a time window, optionally filtered by severity, channel, integration, alert ID/key, active/recovered/muted state. --asc sorts oldest first; --by-updated-at applies the window to last-updated time. No server-side title/text filter — to search by title, pipe --json to jq: 'select(.title|test(\"pat\";\"i\"))'. In json/toon mode, --fields projects each row to just the named fields (e.g. --fields alert_id,title,alert_severity,created_at) so you get a compact record without piping to jq. --limit max 100; --since/--until window must be < 31 days.", "Alerts", "ReadList"),
3535
RunE: func(cmd *cobra.Command, args []string) error {
3636
return runCommand(cmd, args, func(ctx *RunContext) error {
3737
if active && recovered {
@@ -54,6 +54,10 @@ func newAlertListCmd() *cobra.Command {
5454
}
5555
req.Limit = limit
5656
req.Page = page
57+
req.Asc = asc
58+
req.ByUpdatedAt = byUpdatedAt
59+
req.AlertIDs = parseStringSlice(alertIDs)
60+
req.AlertKeys = parseStringSlice(alertKeys)
5761

5862
// Preserve legacy semantics: --active sends is_active=true,
5963
// --recovered sends is_active=false, neither omits the filter.
@@ -118,6 +122,10 @@ func newAlertListCmd() *cobra.Command {
118122
cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
119123
cmd.Flags().StringVar(&integration, "integration", "", "Comma-separated integration IDs")
120124
cmd.Flags().BoolVar(&muted, "muted", false, "Show ever-muted only")
125+
cmd.Flags().StringVar(&alertIDs, "alert-ids", "", "Comma-separated alert IDs to return")
126+
cmd.Flags().StringVar(&alertKeys, "alert-keys", "", "Comma-separated alert deduplication keys")
127+
cmd.Flags().BoolVar(&asc, "asc", false, "Sort oldest first by start time (default newest first)")
128+
cmd.Flags().BoolVar(&byUpdatedAt, "by-updated-at", false, "Apply --since/--until to the last-updated time instead of the start time")
121129
cmd.Flags().StringVar(&since, "since", "24h", "Start time")
122130
cmd.Flags().StringVar(&until, "until", "now", "End time")
123131
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)")

‎internal/cli/alert_event.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func newAlertEventCmd() *cobra.Command {
2020
func newAlertEventListCmd() *cobra.Command {
2121
var severity, channel, integration, integrationType, since, until, fields string
2222
var limit, page int
23+
var asc bool
2324

2425
cmd := &cobra.Command{
2526
Use: "list",
@@ -46,6 +47,7 @@ func newAlertEventListCmd() *cobra.Command {
4647
}
4748
input.Limit = limit
4849
input.Page = page
50+
input.Asc = asc
4951

5052
if severity != "" {
5153
// go-flashduty takes severities as a comma-separated string.
@@ -120,6 +122,7 @@ func newAlertEventListCmd() *cobra.Command {
120122
registerEnumFlag(cmd, "severity", severityEnum...)
121123
cmd.Flags().StringVar(&integration, "integration", "", "Comma-separated integration IDs")
122124
cmd.Flags().StringVar(&integrationType, "integration-type", "", "Comma-separated integration types (plugin keys, e.g. AliCloud,Prometheus) — not integration IDs; use --integration for that")
125+
cmd.Flags().BoolVar(&asc, "asc", false, "Sort oldest first (default newest first)")
123126
cmd.Flags().StringVar(&since, "since", "1h", "Start time")
124127
cmd.Flags().StringVar(&until, "until", "now", "End time")
125128
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)")

‎internal/cli/alert_test.go‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cli
22

33
import (
44
"fmt"
5+
"os"
6+
"path/filepath"
57
"strings"
68
"testing"
79
)
@@ -214,3 +216,86 @@ func TestCommandAlertMergeDataAndCommentFileBothStdinErrors(t *testing.T) {
214216
t.Fatalf("[alert-merge-double-stdin] expected the double-stdin-read error naming both flags, got: %v", err)
215217
}
216218
}
219+
220+
func TestCommandAlertListFilterFlagsReachWire(t *testing.T) {
221+
saveAndResetGlobals(t)
222+
stub := newGFStub(t)
223+
224+
if _, err := execCommand("alert", "list", "--alert-ids", "a1,a2", "--alert-keys", "k1, k2", "--asc", "--by-updated-at"); err != nil {
225+
t.Fatalf("[alert-list-filters] unexpected error: %v", err)
226+
}
227+
if stub.lastPath != "/alert/list" {
228+
t.Fatalf("[alert-list-filters] expected /alert/list, got %q", stub.lastPath)
229+
}
230+
for _, f := range []string{"asc", "by_updated_at"} {
231+
if got := stub.lastBody[f]; got != true {
232+
t.Errorf("[alert-list-filters] %s: want true, got %#v", f, got)
233+
}
234+
}
235+
for field, want := range map[string]string{"alert_ids": "a1,a2", "alert_keys": "k1,k2"} {
236+
got, _ := stub.lastBody[field].([]any)
237+
var parts []string
238+
for _, v := range got {
239+
parts = append(parts, fmt.Sprint(v))
240+
}
241+
if strings.Join(parts, ",") != want {
242+
t.Errorf("[alert-list-filters] %s: want %q, got %#v", field, want, stub.lastBody[field])
243+
}
244+
}
245+
}
246+
247+
func TestCommandAlertListFilterFlagsDefaultOmitted(t *testing.T) {
248+
saveAndResetGlobals(t)
249+
stub := newGFStub(t)
250+
251+
if _, err := execCommand("alert", "list"); err != nil {
252+
t.Fatalf("[alert-list-filters-default] unexpected error: %v", err)
253+
}
254+
for _, f := range []string{"alert_ids", "alert_keys", "asc", "by_updated_at"} {
255+
if _, ok := stub.lastBody[f]; ok {
256+
t.Errorf("[alert-list-filters-default] %s should be omitted by default, got %#v", f, stub.lastBody[f])
257+
}
258+
}
259+
}
260+
261+
func TestCommandAlertEventListAsc(t *testing.T) {
262+
for _, tc := range []struct {
263+
name string
264+
extra []string
265+
want any
266+
}{
267+
{name: "default omits asc", want: nil},
268+
{name: "flag sends asc", extra: []string{"--asc"}, want: true},
269+
} {
270+
t.Run(tc.name, func(t *testing.T) {
271+
saveAndResetGlobals(t)
272+
stub := newGFStub(t)
273+
274+
if _, err := execCommand(append([]string{"alert-event", "list"}, tc.extra...)...); err != nil {
275+
t.Fatalf("[alert-event-list-asc] unexpected error: %v", err)
276+
}
277+
if stub.lastPath != "/alert-event/list" {
278+
t.Fatalf("[alert-event-list-asc] expected /alert-event/list, got %q", stub.lastPath)
279+
}
280+
if got := stub.lastBody["asc"]; got != tc.want {
281+
t.Fatalf("[alert-event-list-asc] asc: want %#v, got %#v", tc.want, got)
282+
}
283+
})
284+
}
285+
}
286+
287+
func TestCommandAlertMergeCommentFileReachesWire(t *testing.T) {
288+
saveAndResetGlobals(t)
289+
stub := newGFStub(t)
290+
291+
path := filepath.Join(t.TempDir(), "comment.txt")
292+
if err := os.WriteFile(path, []byte("merge `reason` $(x)"), 0o600); err != nil {
293+
t.Fatal(err)
294+
}
295+
if _, err := execCommand("alert", "merge", "a1", "--incident-id", "i1", "--comment-file", path); err != nil {
296+
t.Fatalf("[alert-merge-comment] unexpected error: %v", err)
297+
}
298+
if got := stub.lastBody["comment"]; got != "merge `reason` $(x)" {
299+
t.Fatalf("[alert-merge-comment] comment: got %#v", got)
300+
}
301+
}

‎internal/cli/audit.go‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@ func newAuditCmd() *cobra.Command {
1717
}
1818

1919
func newAuditSearchCmd() *cobra.Command {
20-
var since, until, operation string
20+
var since, until, operation, requestID string
21+
var isWrite, isDangerous bool
2122
var person int64
2223
var limit, page int
2324

2425
cmd := &cobra.Command{
2526
Use: "search",
2627
Short: "Search audit logs",
27-
Long: curatedLong("Search audit logs within a time window, optionally filtered by person and operation type. The --since/--until window must be < 90 days; --limit max is 99.", "AuditLogs", "Search"),
28+
Long: curatedLong("Search audit logs within a time window, optionally filtered by person, operation type, write/read, risk level and request ID. The --since/--until window must be < 90 days; --limit max is 99.", "AuditLogs", "Search"),
2829
RunE: func(cmd *cobra.Command, args []string) error {
2930
return runCommand(cmd, args, func(ctx *RunContext) error {
3031
startTime, err := timeutil.Parse(since)
@@ -41,6 +42,13 @@ func newAuditSearchCmd() *cobra.Command {
4142
EndTime: endTime,
4243
Limit: int64(limit),
4344
PersonID: uint64(person),
45+
RequestID: requestID,
46+
}
47+
if cmd.Flags().Changed("is-write") {
48+
input.IsWrite = &isWrite
49+
}
50+
if cmd.Flags().Changed("is-dangerous") {
51+
input.IsDangerous = &isDangerous
4452
}
4553
if operation != "" {
4654
input.Operations = parseStringSlice(operation)
@@ -105,6 +113,9 @@ func newAuditSearchCmd() *cobra.Command {
105113
cmd.Flags().StringVar(&until, "until", "now", "End time")
106114
cmd.Flags().Int64Var(&person, "person", 0, "Filter by person ID")
107115
cmd.Flags().StringVar(&operation, "operation", "", "Filter by exact operation name(s) from 'flashduty audit operation-list' (e.g. monitRule:write:update); comma-separate to match several in one call. Prefixes do NOT match (\"monitRule\" returns nothing).")
116+
cmd.Flags().StringVar(&requestID, "request-id", "", "Filter to a single request by its request ID")
117+
cmd.Flags().BoolVar(&isWrite, "is-write", false, "Only write operations; --is-write=false returns only read operations")
118+
cmd.Flags().BoolVar(&isDangerous, "is-dangerous", false, "Only high-risk (dangerous) operations")
108119
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 99)")
109120
cmd.Flags().IntVar(&page, "page", 1, "Page number")
110121

‎internal/cli/broker_dial_other.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"net/http"
88
)
99

10-
func newBrokerHTTPClient(int) *http.Client { return nil }
10+
func newBrokerHTTPClient(int) (*http.Client, error) { return nil, errBrokerUnsupported }
1111

1212
var errBrokerUnsupported = errors.New("flashduty: broker mode is not supported on this platform")
1313

‎internal/cli/broker_dial_unix.go‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,6 @@ import (
1414
"time"
1515
)
1616

17-
// errBrokerUnsupported is returned when broker mode is requested on a build that
18-
// cannot provide it. On unix this is effectively unreachable (newBrokerHTTPClient
19-
// never returns nil), but defaultNewClient references it on every platform.
20-
var errBrokerUnsupported = errors.New("flashduty: broker mode is not supported on this platform")
21-
2217
// errBrokerClosed is returned (wrapped) when the runner-side broker control
2318
// channel is gone: the runner exited, or reclaimed the channel once the
2419
// command that started this process finished, so fduty calls from a
@@ -110,7 +105,21 @@ func (d *brokerDialer) dial(_ context.Context, _, _ string) (net.Conn, error) {
110105
// every connection over the inherited control fd. Timeout matches the SDK's
111106
// historical default (30s) so behavior is unchanged for non-streaming calls;
112107
// streaming export relies on request context like before.
113-
func newBrokerHTTPClient(credFD int) *http.Client {
108+
//
109+
// It first checks that credFD is an open socket in this process. The runner
110+
// hands the control end to bash, and only processes that inherit fd credFD
111+
// reach fduty with it intact: Python's subprocess (close_fds=True by default),
112+
// Node's child_process and sudo all close it. Without the check that surfaces
113+
// as a handshake EBADF/ENOTSOCK at the first request, after the SDK's URL
114+
// prefix, with nothing saying how to fix it.
115+
func newBrokerHTTPClient(credFD int) (*http.Client, error) {
116+
if _, err := syscall.GetsockoptInt(credFD, syscall.SOL_SOCKET, syscall.SO_TYPE); err != nil {
117+
return nil, fmt.Errorf("FLASHDUTY_CRED_FD=%d is not an open socket in this process (%v): "+
118+
"the program that started fduty did not pass the credential channel down. "+
119+
"Run fduty from the shell, or keep fd %d open when spawning it: "+
120+
"Python subprocess.run(cmd, pass_fds=(%d,)); Node: set entry %d of spawn's stdio array to %d",
121+
credFD, err, credFD, credFD, credFD, credFD)
122+
}
114123
d := &brokerDialer{credFD: credFD}
115124
return &http.Client{
116125
Timeout: 30 * time.Second,
@@ -125,5 +134,5 @@ func newBrokerHTTPClient(credFD int) *http.Client {
125134
IdleConnTimeout: 90 * time.Second,
126135
ResponseHeaderTimeout: 0,
127136
},
128-
}
137+
}, nil
129138
}

‎internal/cli/broker_dial_unix_test.go‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,9 @@ func TestBrokerHTTPClient_DialAndRewrite(t *testing.T) {
106106
defer func() { _ = syscall.Close(childFD) }()
107107
defer stop()
108108

109-
client := newBrokerHTTPClient(childFD)
110-
if client == nil {
111-
t.Fatal("newBrokerHTTPClient returned nil")
109+
client, err := newBrokerHTTPClient(childFD)
110+
if err != nil {
111+
t.Fatalf("newBrokerHTTPClient: %v", err)
112112
}
113113
defer client.CloseIdleConnections() // release dispatched keep-alive conns
114114
// The CLI's base URL is an http placeholder; broker rewrites host.
@@ -208,6 +208,41 @@ func TestDefaultNewClient_RejectsStdioFD(t *testing.T) {
208208
}
209209
}
210210

211+
// TestDefaultNewClient_CredFDNotInherited covers a caller that drops the
212+
// inherited control fd before exec (Python's subprocess closes fds >= 3 by
213+
// default): the fd number is then closed, or reused by an unrelated file.
214+
// Either way defaultNewClient must fail up front with an error that names the
215+
// fd and the fix, instead of a handshake errno at the first request.
216+
func TestDefaultNewClient_CredFDNotInherited(t *testing.T) {
217+
t.Setenv("HOME", t.TempDir())
218+
t.Setenv("FLASHDUTY_APP_KEY", "")
219+
220+
f, err := os.CreateTemp(t.TempDir(), "not-a-socket")
221+
if err != nil {
222+
t.Fatal(err)
223+
}
224+
defer func() { _ = f.Close() }()
225+
pair, err := syscall.Socketpair(syscall.AF_UNIX, controlSockType, 0)
226+
if err != nil {
227+
t.Fatalf("socketpair: %v", err)
228+
}
229+
closedFD := pair[0]
230+
_ = syscall.Close(pair[0])
231+
_ = syscall.Close(pair[1])
232+
233+
for name, fd := range map[string]int{"closed fd": closedFD, "regular file": int(f.Fd())} {
234+
t.Setenv("FLASHDUTY_CRED_FD", strconv.Itoa(fd))
235+
_, err := defaultNewClient()
236+
if err == nil {
237+
t.Fatalf("%s: defaultNewClient must fail", name)
238+
}
239+
want := "FLASHDUTY_CRED_FD=" + strconv.Itoa(fd) + " is not an open socket"
240+
if msg := err.Error(); !strings.HasPrefix(msg, want) || !strings.Contains(msg, "pass_fds=("+strconv.Itoa(fd)+",)") {
241+
t.Fatalf("%s: error must start with %q and name pass_fds, got: %v", name, want, msg)
242+
}
243+
}
244+
}
245+
211246
// TestBrokerHTTPClient_RefusedReturnsError verifies the dialer surfaces the
212247
// broker's 0xFF refusal (e.g. the runner failed to mint a connection) as a real
213248
// error instead of hanging or wrapping a nil conn.
@@ -232,7 +267,10 @@ func TestBrokerHTTPClient_RefusedReturnsError(t *testing.T) {
232267
}
233268
}()
234269

235-
client := newBrokerHTTPClient(childFD)
270+
client, err := newBrokerHTTPClient(childFD)
271+
if err != nil {
272+
t.Fatalf("newBrokerHTTPClient: %v", err)
273+
}
236274
req, _ := http.NewRequestWithContext(context.Background(), "GET",
237275
"http://flashduty.broker.local/x?app_key=SENTINEL", nil)
238276
if _, err := client.Do(req); err == nil {

‎internal/cli/change.go‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"encoding/json"
45
"fmt"
56

67
"github.com/flashcatcloud/go-flashduty"
@@ -20,7 +21,9 @@ func newChangeListCmd() *cobra.Command {
2021
var channel string
2122
var since, until string
2223
var limit, page int
23-
var query, integration string
24+
var query, integration, filters string
25+
var orderby string
26+
var asc, includeEvents bool
2427

2528
cmd := &cobra.Command{
2629
Use: "list",
@@ -72,6 +75,14 @@ func newChangeListCmd() *cobra.Command {
7275
input.IntegrationIDs = integrationIDs
7376
}
7477
input.Query = query
78+
input.Orderby = orderby
79+
input.Asc = asc
80+
input.IncludeEvents = includeEvents
81+
if filters != "" {
82+
if err := json.Unmarshal([]byte(filters), &input.Filters); err != nil {
83+
return fmt.Errorf("invalid --filters: %w", err)
84+
}
85+
}
7586

7687
result, _, err := ctx.Client.Changes.List(cmdContext(ctx.Cmd), input)
7788
if err != nil {
@@ -94,6 +105,10 @@ func newChangeListCmd() *cobra.Command {
94105
cmd.Flags().StringVar(&channel, "channel", "", "Comma-separated channel IDs")
95106
cmd.Flags().StringVar(&query, "query", "", "Free-text/regex search over change fields")
96107
cmd.Flags().StringVar(&integration, "integration", "", "Comma-separated reporting integration IDs")
108+
cmd.Flags().StringVar(&filters, "filters", "", `Structured filters ANDed onto the query, as a JSON array of {"key","oper","vals"} (oper IN or NOTIN; key like labels.env). Keys starting with "incident" are ignored`)
109+
cmd.Flags().StringVar(&orderby, "orderby", "", "Sort field: start_time (default) or last_time")
110+
cmd.Flags().BoolVar(&asc, "asc", false, "Sort in ascending order")
111+
cmd.Flags().BoolVar(&includeEvents, "include-events", false, "Include the underlying change events for each change")
97112
cmd.Flags().StringVar(&since, "since", "24h", "Start time (accepts 7d/24h/now, RFC3339, or Unix epoch; window must be < 31 days)")
98113
cmd.Flags().StringVar(&until, "until", "now", "End time (accepts 7d/24h/now, RFC3339, or Unix epoch)")
99114
cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)")

0 commit comments

Comments
 (0)