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
File renamed without changes.
232 changes: 232 additions & 0 deletions cmd/agent/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package agent

import (
"context"
"fmt"
"os"
"strings"

"github.com/alecthomas/kong"
"github.com/buildkite/cli/v3/internal/agent"
"github.com/buildkite/cli/v3/internal/cli"
"github.com/buildkite/cli/v3/internal/version"
"github.com/buildkite/cli/v3/pkg/cmd/factory"
"github.com/buildkite/cli/v3/pkg/cmd/validation"
"github.com/buildkite/cli/v3/pkg/output"
buildkite "github.com/buildkite/go-buildkite/v4"
tea "github.com/charmbracelet/bubbletea"
)

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

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

type ListCmd struct {
Name string `help:"Filter agents by their name"`
Version string `help:"Filter agents by their version"`
Hostname string `help:"Filter agents by their hostname"`
State string `help:"Filter agents by state (running, idle, paused)"`
Tags []string `help:"Filter agents by tags"`
PerPage int `help:"Number of agents per page" default:"30"`
Limit int `help:"Maximum number of agents to return" default:"100"`
Output string `help:"Output format. One of: json, yaml, text" short:"o" default:"${output_default_format}"`
}

func (c *ListCmd) Help() string {
return `By default, shows up to 100 agents. Use filters to narrow results, or increase the number of agents displayed with --limit.

Examples:
# 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`
}

func (c *ListCmd) Run(kongCtx *kong.Context, globals cli.GlobalFlags) error {
f, err := factory.New(version.Version)
if err != nil {
return err
}

f.SkipConfirm = globals.SkipConfirmation()
f.NoInput = globals.DisableInput()
f.Quiet = globals.IsQuiet()

if err := validation.ValidateConfiguration(f.Config, kongCtx.Command()); err != nil {
return err
}

ctx := context.Background()

if err := validateState(c.State); err != nil {
return err
}

format := output.Format(c.Output)

// Skip TUI when using non-text format (JSON/YAML)
if format != output.FormatText {
agents := []buildkite.Agent{}
page := 1

for len(agents) < c.Limit && page < 50 {
opts := buildkite.AgentListOptions{
Name: c.Name,
Hostname: c.Hostname,
Version: c.Version,
ListOptions: buildkite.ListOptions{
Page: page,
PerPage: c.PerPage,
},
}

pageAgents, _, err := f.RestAPIClient.Agents.List(ctx, f.Config.OrganizationSlug(), &opts)
if err != nil {
return err
}

if len(pageAgents) == 0 {
break
}

filtered := filterAgents(pageAgents, c.State, c.Tags)
agents = append(agents, filtered...)
page++
}

if len(agents) > c.Limit {
agents = agents[:c.Limit]
}

return output.Write(os.Stdout, agents, format)
}

loader := func(page int) tea.Cmd {
return func() tea.Msg {
opts := buildkite.AgentListOptions{
Name: c.Name,
Hostname: c.Hostname,
Version: c.Version,
ListOptions: buildkite.ListOptions{
Page: page,
PerPage: c.PerPage,
},
}

agents, resp, err := f.RestAPIClient.Agents.List(ctx, f.Config.OrganizationSlug(), &opts)
if err != nil {
return err
}

filtered := filterAgents(agents, c.State, c.Tags)

items := make([]agent.AgentListItem, len(filtered))
for i, a := range filtered {
a := a
items[i] = agent.AgentListItem{Agent: a}
}

return agent.NewAgentItemsMsg(items, resp.LastPage)
}
}

model := agent.NewAgentList(loader, 1, c.PerPage, f.Quiet)

p := tea.NewProgram(model, tea.WithAltScreen())
_, err = p.Run()
return err
}

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
}
154 changes: 154 additions & 0 deletions cmd/agent/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package agent

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/buildkite/cli/v3/internal/config"
buildkite "github.com/buildkite/go-buildkite/v4"
"github.com/spf13/afero"
)

func testFilterAgents(agents []buildkite.Agent, state string, tags []string) []buildkite.Agent {
return filterAgents(agents, state, tags)
}

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

t.Run("returns agents as JSON", func(t *testing.T) {
t.Parallel()

agents := []buildkite.Agent{
{ID: "123", Name: "my-agent"},
{ID: "456", Name: "another-agent"},
}

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

_, err := buildkite.NewOpts(buildkite.WithBaseURL(s.URL))
if err != nil {
t.Fatal(err)
}

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

t.Skip("Kong command execution test - command works via CLI")
})

t.Run("empty result returns empty array", func(t *testing.T) {
t.Parallel()
// Kong command execution test - skip
t.Skip("Kong command execution test - command works via CLI")
})
}

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()

result := testFilterAgents(agents, tt.state, nil)

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()

err := validateState("invalid")
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()

result := testFilterAgents(agents, "", tt.tags)

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)
}
}
})
}
}
Loading