Skip to content

Commit 1697ce1

Browse files
authored
Adding --state and --tags filters to bk agent list (#561)
1 parent 9ff73e9 commit 1697ce1

File tree

4 files changed

+304
-8
lines changed

4 files changed

+304
-8
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ require (
88
github.com/AlecAivazis/survey/v2 v2.3.7
99
github.com/MakeNowJust/heredoc v1.0.0
1010
github.com/alecthomas/kong v1.12.1
11-
github.com/buildkite/go-buildkite/v4 v4.9.1
11+
github.com/buildkite/go-buildkite/v4 v4.10.0
1212
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
1313
github.com/charmbracelet/bubbletea v1.3.10
1414
github.com/charmbracelet/huh v0.8.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwN
4747
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
4848
github.com/bradleyjkemp/cupaloy/v2 v2.6.0 h1:knToPYa2xtfg42U3I6punFEjaGFKWQRXJwj0JTv4mTs=
4949
github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
50-
github.com/buildkite/go-buildkite/v4 v4.9.1 h1:dA32pinTsHMTCHO84awc9W9Y21knlPjd/ZOSntMPIb0=
51-
github.com/buildkite/go-buildkite/v4 v4.9.1/go.mod h1:DlebrRJqpZttXDjCW+MJ1QyW9AN++ZWt/UbPtKdbSSk=
50+
github.com/buildkite/go-buildkite/v4 v4.10.0 h1:U3mYmDNLJqe+703Ztmf23ZA6eE/CnSsWevXyV9o9N4Q=
51+
github.com/buildkite/go-buildkite/v4 v4.10.0/go.mod h1:DlebrRJqpZttXDjCW+MJ1QyW9AN++ZWt/UbPtKdbSSk=
5252
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
5353
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
5454
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=

pkg/cmd/agent/list.go

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package agent
22

33
import (
4+
"fmt"
5+
"strings"
6+
47
"github.com/MakeNowJust/heredoc"
58
"github.com/buildkite/cli/v3/internal/agent"
69
"github.com/buildkite/cli/v3/pkg/cmd/factory"
@@ -10,8 +13,17 @@ import (
1013
"github.com/spf13/cobra"
1114
)
1215

16+
const (
17+
stateRunning = "running"
18+
stateIdle = "idle"
19+
statePaused = "paused"
20+
)
21+
22+
var validStates = []string{stateRunning, stateIdle, statePaused}
23+
1324
func NewCmdAgentList(f *factory.Factory) *cobra.Command {
14-
var name, version, hostname string
25+
var name, version, hostname, state string
26+
var tags []string
1527
var perpage, limit int
1628

1729
cmd := cobra.Command{
@@ -24,7 +36,42 @@ func NewCmdAgentList(f *factory.Factory) *cobra.Command {
2436
2537
By default, shows up to 100 agents. Use filters to narrow results, or increase the number of agents displayed with --limit.
2638
`),
39+
Example: heredoc.Doc(`
40+
# List all agents
41+
$ bk agent list
42+
43+
# List agents with JSON output
44+
$ bk agent list --output json
45+
46+
# List only running agents (currently executing jobs)
47+
$ bk agent list --state running
48+
49+
# List only idle agents (connected but not running jobs)
50+
$ bk agent list --state idle
51+
52+
# List only paused agents
53+
$ bk agent list --state paused
54+
55+
# Filter agents by hostname
56+
$ bk agent list --hostname my-server-01
57+
58+
# Combine state and hostname filters
59+
$ bk agent list --state idle --hostname my-server-01
60+
61+
# Filter agents by tags
62+
$ bk agent list --tags queue=default
63+
64+
# Filter agents by multiple tags (all must match)
65+
$ bk agent list --tags queue=default --tags os=linux
66+
67+
# Multiple filters with output format
68+
$ bk agent list --state running --version 3.107.2 --output json
69+
`),
2770
RunE: func(cmd *cobra.Command, args []string) error {
71+
if err := validateState(state); err != nil {
72+
return err
73+
}
74+
2875
format, err := output.GetFormat(cmd.Flags())
2976
if err != nil {
3077
return err
@@ -54,7 +101,8 @@ func NewCmdAgentList(f *factory.Factory) *cobra.Command {
54101
break
55102
}
56103

57-
agents = append(agents, pageAgents...)
104+
filtered := filterAgents(pageAgents, state, tags)
105+
agents = append(agents, filtered...)
58106
page++
59107
}
60108

@@ -78,13 +126,14 @@ func NewCmdAgentList(f *factory.Factory) *cobra.Command {
78126
}
79127

80128
agents, resp, err := f.RestAPIClient.Agents.List(cmd.Context(), f.Config.OrganizationSlug(), &opts)
81-
items := make([]agent.AgentListItem, len(agents))
82-
83129
if err != nil {
84130
return err
85131
}
86132

87-
for i, a := range agents {
133+
filtered := filterAgents(agents, state, tags)
134+
135+
items := make([]agent.AgentListItem, len(filtered))
136+
for i, a := range filtered {
88137
a := a
89138
items[i] = agent.AgentListItem{Agent: a}
90139
}
@@ -104,9 +153,76 @@ func NewCmdAgentList(f *factory.Factory) *cobra.Command {
104153
cmd.Flags().StringVar(&name, "name", "", "Filter agents by their name")
105154
cmd.Flags().StringVar(&version, "version", "", "Filter agents by their version")
106155
cmd.Flags().StringVar(&hostname, "hostname", "", "Filter agents by their hostname")
156+
cmd.Flags().StringVar(&state, "state", "", "Filter agents by state (running, idle, paused)")
157+
cmd.Flags().StringSliceVar(&tags, "tags", []string{}, "Filter agents by tags")
107158
cmd.Flags().IntVar(&perpage, "per-page", 30, "Number of agents per page")
108159
cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of agents to return")
109160
output.AddFlags(cmd.Flags())
110161

111162
return &cmd
112163
}
164+
165+
func validateState(state string) error {
166+
if state == "" {
167+
return nil
168+
}
169+
170+
normalized := strings.ToLower(state)
171+
for _, valid := range validStates {
172+
if normalized == valid {
173+
return nil
174+
}
175+
}
176+
177+
return fmt.Errorf("invalid state %q: must be one of %s, %s, or %s", state, stateRunning, stateIdle, statePaused)
178+
}
179+
180+
func filterAgents(agents []buildkite.Agent, state string, tags []string) []buildkite.Agent {
181+
filtered := make([]buildkite.Agent, 0, len(agents))
182+
for _, a := range agents {
183+
if matchesState(a, state) && matchesTags(a, tags) {
184+
filtered = append(filtered, a)
185+
}
186+
}
187+
return filtered
188+
}
189+
190+
func matchesState(a buildkite.Agent, state string) bool {
191+
if state == "" {
192+
return true
193+
}
194+
195+
normalized := strings.ToLower(state)
196+
switch normalized {
197+
case stateRunning:
198+
return a.Job != nil
199+
case stateIdle:
200+
return a.Job == nil && (a.Paused == nil || !*a.Paused)
201+
case statePaused:
202+
return a.Paused != nil && *a.Paused
203+
default:
204+
return false
205+
}
206+
}
207+
208+
func matchesTags(a buildkite.Agent, tags []string) bool {
209+
if len(tags) == 0 {
210+
return true
211+
}
212+
213+
for _, tag := range tags {
214+
if !hasTag(a.Metadata, tag) {
215+
return false
216+
}
217+
}
218+
return true
219+
}
220+
221+
func hasTag(metadata []string, tag string) bool {
222+
for _, meta := range metadata {
223+
if meta == tag {
224+
return true
225+
}
226+
}
227+
return false
228+
}

pkg/cmd/agent/list_test.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,183 @@ func TestCmdAgentList(t *testing.T) {
116116
}
117117
})
118118
}
119+
120+
func TestAgentListStateFilter(t *testing.T) {
121+
t.Parallel()
122+
123+
paused := true
124+
notPaused := false
125+
126+
agents := []buildkite.Agent{
127+
{ID: "1", Name: "running-agent", Job: &buildkite.Job{ID: "job-1"}},
128+
{ID: "2", Name: "idle-agent"},
129+
{ID: "3", Name: "paused-agent", Paused: &paused},
130+
{ID: "4", Name: "idle-not-paused", Paused: &notPaused},
131+
}
132+
133+
tests := []struct {
134+
state string
135+
want []string // agent IDs
136+
}{
137+
{"running", []string{"1"}},
138+
{"RUNNING", []string{"1"}},
139+
{"idle", []string{"2", "4"}},
140+
{"paused", []string{"3"}},
141+
{"", []string{"1", "2", "3", "4"}},
142+
}
143+
144+
for _, tt := range tests {
145+
t.Run(tt.state, func(t *testing.T) {
146+
t.Parallel()
147+
148+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
149+
w.Header().Set("Content-Type", "application/json")
150+
page := r.URL.Query().Get("page")
151+
if page == "" || page == "1" {
152+
json.NewEncoder(w).Encode(agents)
153+
} else {
154+
json.NewEncoder(w).Encode([]buildkite.Agent{})
155+
}
156+
}))
157+
defer s.Close()
158+
159+
apiClient, _ := buildkite.NewOpts(buildkite.WithBaseURL(s.URL))
160+
conf := config.New(afero.NewMemMapFs(), nil)
161+
conf.SelectOrganization("test", true)
162+
163+
factory := &factory.Factory{
164+
RestAPIClient: apiClient,
165+
Config: conf,
166+
}
167+
168+
cmd := agent.NewCmdAgentList(factory)
169+
args := []string{"-o", "json"}
170+
if tt.state != "" {
171+
args = append(args, "--state", tt.state)
172+
}
173+
cmd.SetArgs(args)
174+
175+
var buf bytes.Buffer
176+
cmd.SetOut(&buf)
177+
178+
if err := cmd.Execute(); err != nil {
179+
t.Fatal(err)
180+
}
181+
182+
var result []buildkite.Agent
183+
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
184+
t.Fatal(err)
185+
}
186+
187+
if len(result) != len(tt.want) {
188+
t.Errorf("got %d agents, want %d", len(result), len(tt.want))
189+
}
190+
191+
for i, id := range tt.want {
192+
if i >= len(result) || result[i].ID != id {
193+
t.Errorf("agent %d: got ID %q, want %q", i, result[i].ID, id)
194+
}
195+
}
196+
})
197+
}
198+
}
199+
200+
func TestAgentListInvalidState(t *testing.T) {
201+
t.Parallel()
202+
203+
conf := config.New(afero.NewMemMapFs(), nil)
204+
conf.SelectOrganization("test", true)
205+
206+
factory := &factory.Factory{
207+
Config: conf,
208+
}
209+
210+
cmd := agent.NewCmdAgentList(factory)
211+
cmd.SetArgs([]string{"--state", "invalid"})
212+
213+
err := cmd.Execute()
214+
if err == nil {
215+
t.Fatal("expected error for invalid state, got nil")
216+
}
217+
218+
if !strings.Contains(err.Error(), "invalid state") {
219+
t.Errorf("expected error to mention 'invalid state', got: %v", err)
220+
}
221+
}
222+
223+
func TestAgentListTagsFilter(t *testing.T) {
224+
t.Parallel()
225+
226+
agents := []buildkite.Agent{
227+
{ID: "1", Name: "default-linux", Metadata: []string{"queue=default", "os=linux"}},
228+
{ID: "2", Name: "deploy-macos", Metadata: []string{"queue=deploy", "os=macos"}},
229+
{ID: "3", Name: "default-macos", Metadata: []string{"queue=default", "os=macos"}},
230+
{ID: "4", Name: "no-metadata"},
231+
}
232+
233+
tests := []struct {
234+
name string
235+
tags []string
236+
want []string
237+
}{
238+
{"single tag", []string{"queue=default"}, []string{"1", "3"}},
239+
{"multiple tags AND", []string{"queue=default", "os=linux"}, []string{"1"}},
240+
{"no match", []string{"queue=nonexistent"}, []string{}},
241+
{"no tags filter", []string{}, []string{"1", "2", "3", "4"}},
242+
}
243+
244+
for _, tt := range tests {
245+
t.Run(tt.name, func(t *testing.T) {
246+
t.Parallel()
247+
248+
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
249+
w.Header().Set("Content-Type", "application/json")
250+
page := r.URL.Query().Get("page")
251+
if page == "" || page == "1" {
252+
json.NewEncoder(w).Encode(agents)
253+
} else {
254+
json.NewEncoder(w).Encode([]buildkite.Agent{})
255+
}
256+
}))
257+
defer s.Close()
258+
259+
apiClient, _ := buildkite.NewOpts(buildkite.WithBaseURL(s.URL))
260+
conf := config.New(afero.NewMemMapFs(), nil)
261+
conf.SelectOrganization("test", true)
262+
263+
factory := &factory.Factory{
264+
RestAPIClient: apiClient,
265+
Config: conf,
266+
}
267+
268+
cmd := agent.NewCmdAgentList(factory)
269+
args := []string{"-o", "json"}
270+
for _, tag := range tt.tags {
271+
args = append(args, "--tags", tag)
272+
}
273+
cmd.SetArgs(args)
274+
275+
var buf bytes.Buffer
276+
cmd.SetOut(&buf)
277+
278+
if err := cmd.Execute(); err != nil {
279+
t.Fatal(err)
280+
}
281+
282+
var result []buildkite.Agent
283+
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
284+
t.Fatal(err)
285+
}
286+
287+
if len(result) != len(tt.want) {
288+
t.Errorf("got %d agents, want %d", len(result), len(tt.want))
289+
}
290+
291+
for i, id := range tt.want {
292+
if i >= len(result) || result[i].ID != id {
293+
t.Errorf("agent %d: got ID %q, want %q", i, result[i].ID, id)
294+
}
295+
}
296+
})
297+
}
298+
}

0 commit comments

Comments
 (0)