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 @@ -8,7 +8,7 @@ require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/MakeNowJust/heredoc v1.0.0
github.com/alecthomas/kong v1.12.1
github.com/buildkite/go-buildkite/v4 v4.9.1
github.com/buildkite/go-buildkite/v4 v4.10.0
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v0.8.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwN
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bradleyjkemp/cupaloy/v2 v2.6.0 h1:knToPYa2xtfg42U3I6punFEjaGFKWQRXJwj0JTv4mTs=
github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/buildkite/go-buildkite/v4 v4.9.1 h1:dA32pinTsHMTCHO84awc9W9Y21knlPjd/ZOSntMPIb0=
github.com/buildkite/go-buildkite/v4 v4.9.1/go.mod h1:DlebrRJqpZttXDjCW+MJ1QyW9AN++ZWt/UbPtKdbSSk=
github.com/buildkite/go-buildkite/v4 v4.10.0 h1:U3mYmDNLJqe+703Ztmf23ZA6eE/CnSsWevXyV9o9N4Q=
github.com/buildkite/go-buildkite/v4 v4.10.0/go.mod h1:DlebrRJqpZttXDjCW+MJ1QyW9AN++ZWt/UbPtKdbSSk=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
Expand Down
126 changes: 121 additions & 5 deletions pkg/cmd/agent/list.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package agent

import (
"fmt"
"strings"

"github.com/MakeNowJust/heredoc"
"github.com/buildkite/cli/v3/internal/agent"
"github.com/buildkite/cli/v3/pkg/cmd/factory"
Expand All @@ -10,8 +13,17 @@ import (
"github.com/spf13/cobra"
)

const (
stateRunning = "running"
stateIdle = "idle"
statePaused = "paused"
)

var validStates = []string{stateRunning, stateIdle, statePaused}

func NewCmdAgentList(f *factory.Factory) *cobra.Command {
var name, version, hostname string
var name, version, hostname, state string
var tags []string
var perpage, limit int

cmd := cobra.Command{
Expand All @@ -24,7 +36,42 @@ func NewCmdAgentList(f *factory.Factory) *cobra.Command {

By default, shows up to 100 agents. Use filters to narrow results, or increase the number of agents displayed with --limit.
`),
Example: heredoc.Doc(`
# List all agents
$ bk agent list

# List agents with JSON output
$ bk agent list --output json

# List only running agents (currently executing jobs)
$ bk agent list --state running

# List only idle agents (connected but not running jobs)
$ bk agent list --state idle

# List only paused agents
$ bk agent list --state paused

# Filter agents by hostname
$ bk agent list --hostname my-server-01

# Combine state and hostname filters
$ bk agent list --state idle --hostname my-server-01

# Filter agents by tags
$ bk agent list --tags queue=default

# Filter agents by multiple tags (all must match)
$ bk agent list --tags queue=default --tags os=linux

# Multiple filters with output format
$ bk agent list --state running --version 3.107.2 --output json
`),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateState(state); err != nil {
return err
}

format, err := output.GetFormat(cmd.Flags())
if err != nil {
return err
Expand Down Expand Up @@ -54,7 +101,8 @@ func NewCmdAgentList(f *factory.Factory) *cobra.Command {
break
}

agents = append(agents, pageAgents...)
filtered := filterAgents(pageAgents, state, tags)
agents = append(agents, filtered...)
page++
}

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

agents, resp, err := f.RestAPIClient.Agents.List(cmd.Context(), f.Config.OrganizationSlug(), &opts)
items := make([]agent.AgentListItem, len(agents))

if err != nil {
return err
}

for i, a := range agents {
filtered := filterAgents(agents, state, tags)

items := make([]agent.AgentListItem, len(filtered))
for i, a := range filtered {
a := a
items[i] = agent.AgentListItem{Agent: a}
}
Expand All @@ -104,9 +153,76 @@ func NewCmdAgentList(f *factory.Factory) *cobra.Command {
cmd.Flags().StringVar(&name, "name", "", "Filter agents by their name")
cmd.Flags().StringVar(&version, "version", "", "Filter agents by their version")
cmd.Flags().StringVar(&hostname, "hostname", "", "Filter agents by their hostname")
cmd.Flags().StringVar(&state, "state", "", "Filter agents by state (running, idle, paused)")
cmd.Flags().StringSliceVar(&tags, "tags", []string{}, "Filter agents by tags")
cmd.Flags().IntVar(&perpage, "per-page", 30, "Number of agents per page")
cmd.Flags().IntVar(&limit, "limit", 100, "Maximum number of agents to return")
output.AddFlags(cmd.Flags())

return &cmd
}

func validateState(state string) error {
if state == "" {
return nil
}

normalized := strings.ToLower(state)
for _, valid := range validStates {
if normalized == valid {
return nil
}
}

return fmt.Errorf("invalid state %q: must be one of %s, %s, or %s", state, stateRunning, stateIdle, statePaused)
}

func filterAgents(agents []buildkite.Agent, state string, tags []string) []buildkite.Agent {
filtered := make([]buildkite.Agent, 0, len(agents))
for _, a := range agents {
if matchesState(a, state) && matchesTags(a, tags) {
filtered = append(filtered, a)
}
}
return filtered
}

func matchesState(a buildkite.Agent, state string) bool {
if state == "" {
return true
}

normalized := strings.ToLower(state)
switch normalized {
case stateRunning:
return a.Job != nil
case stateIdle:
return a.Job == nil && (a.Paused == nil || !*a.Paused)
case statePaused:
return a.Paused != nil && *a.Paused
default:
return false
}
}

func matchesTags(a buildkite.Agent, tags []string) bool {
if len(tags) == 0 {
return true
}

for _, tag := range tags {
if !hasTag(a.Metadata, tag) {
return false
}
}
return true
}

func hasTag(metadata []string, tag string) bool {
for _, meta := range metadata {
if meta == tag {
return true
}
}
return false
}
180 changes: 180 additions & 0 deletions pkg/cmd/agent/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,183 @@ func TestCmdAgentList(t *testing.T) {
}
})
}

func TestAgentListStateFilter(t *testing.T) {
t.Parallel()

paused := true
notPaused := false

agents := []buildkite.Agent{
{ID: "1", Name: "running-agent", Job: &buildkite.Job{ID: "job-1"}},
{ID: "2", Name: "idle-agent"},
{ID: "3", Name: "paused-agent", Paused: &paused},
{ID: "4", Name: "idle-not-paused", Paused: &notPaused},
}

tests := []struct {
state string
want []string // agent IDs
}{
{"running", []string{"1"}},
{"RUNNING", []string{"1"}},
{"idle", []string{"2", "4"}},
{"paused", []string{"3"}},
{"", []string{"1", "2", "3", "4"}},
}

for _, tt := range tests {
t.Run(tt.state, func(t *testing.T) {
t.Parallel()

s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
page := r.URL.Query().Get("page")
if page == "" || page == "1" {
json.NewEncoder(w).Encode(agents)
} else {
json.NewEncoder(w).Encode([]buildkite.Agent{})
}
}))
defer s.Close()

apiClient, _ := buildkite.NewOpts(buildkite.WithBaseURL(s.URL))
conf := config.New(afero.NewMemMapFs(), nil)
conf.SelectOrganization("test", true)

factory := &factory.Factory{
RestAPIClient: apiClient,
Config: conf,
}

cmd := agent.NewCmdAgentList(factory)
args := []string{"-o", "json"}
if tt.state != "" {
args = append(args, "--state", tt.state)
}
cmd.SetArgs(args)

var buf bytes.Buffer
cmd.SetOut(&buf)

if err := cmd.Execute(); err != nil {
t.Fatal(err)
}

var result []buildkite.Agent
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
t.Fatal(err)
}

if len(result) != len(tt.want) {
t.Errorf("got %d agents, want %d", len(result), len(tt.want))
}

for i, id := range tt.want {
if i >= len(result) || result[i].ID != id {
t.Errorf("agent %d: got ID %q, want %q", i, result[i].ID, id)
}
}
})
}
}

func TestAgentListInvalidState(t *testing.T) {
t.Parallel()

conf := config.New(afero.NewMemMapFs(), nil)
conf.SelectOrganization("test", true)

factory := &factory.Factory{
Config: conf,
}

cmd := agent.NewCmdAgentList(factory)
cmd.SetArgs([]string{"--state", "invalid"})

err := cmd.Execute()
if err == nil {
t.Fatal("expected error for invalid state, got nil")
}

if !strings.Contains(err.Error(), "invalid state") {
t.Errorf("expected error to mention 'invalid state', got: %v", err)
}
}

func TestAgentListTagsFilter(t *testing.T) {
t.Parallel()

agents := []buildkite.Agent{
{ID: "1", Name: "default-linux", Metadata: []string{"queue=default", "os=linux"}},
{ID: "2", Name: "deploy-macos", Metadata: []string{"queue=deploy", "os=macos"}},
{ID: "3", Name: "default-macos", Metadata: []string{"queue=default", "os=macos"}},
{ID: "4", Name: "no-metadata"},
}

tests := []struct {
name string
tags []string
want []string
}{
{"single tag", []string{"queue=default"}, []string{"1", "3"}},
{"multiple tags AND", []string{"queue=default", "os=linux"}, []string{"1"}},
{"no match", []string{"queue=nonexistent"}, []string{}},
{"no tags filter", []string{}, []string{"1", "2", "3", "4"}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
page := r.URL.Query().Get("page")
if page == "" || page == "1" {
json.NewEncoder(w).Encode(agents)
} else {
json.NewEncoder(w).Encode([]buildkite.Agent{})
}
}))
defer s.Close()

apiClient, _ := buildkite.NewOpts(buildkite.WithBaseURL(s.URL))
conf := config.New(afero.NewMemMapFs(), nil)
conf.SelectOrganization("test", true)

factory := &factory.Factory{
RestAPIClient: apiClient,
Config: conf,
}

cmd := agent.NewCmdAgentList(factory)
args := []string{"-o", "json"}
for _, tag := range tt.tags {
args = append(args, "--tags", tag)
}
cmd.SetArgs(args)

var buf bytes.Buffer
cmd.SetOut(&buf)

if err := cmd.Execute(); err != nil {
t.Fatal(err)
}

var result []buildkite.Agent
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
t.Fatal(err)
}

if len(result) != len(tt.want) {
t.Errorf("got %d agents, want %d", len(result), len(tt.want))
}

for i, id := range tt.want {
if i >= len(result) || result[i].ID != id {
t.Errorf("agent %d: got ID %q, want %q", i, result[i].ID, id)
}
}
})
}
}