Skip to content

Commit

Permalink
Add command for canceling a running a build
Browse files Browse the repository at this point in the history
  • Loading branch information
dannymidnight committed Aug 28, 2024
1 parent 31ab702 commit 5ad9725
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 0 deletions.
1 change: 1 addition & 0 deletions agent/api.go

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

28 changes: 28 additions & 0 deletions api/builds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package api

import (
"context"
"fmt"
)

type Build struct {
ID string `json:"id"`
}

// CancelBuild cancels a build with the given ID
func (c *Client) CancelBuild(ctx context.Context, id string) (*Build, *Response, error) {
u := fmt.Sprintf("builds/%s/cancel", railsPathEscape(id))

req, err := c.newRequest(ctx, "POST", u, nil)
if err != nil {
return nil, nil, err
}

build := new(Build)
resp, err := c.doRequest(req, build)
if err != nil {
return nil, resp, err
}

return build, resp, nil
}
114 changes: 114 additions & 0 deletions clicommand/build_cancel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package clicommand

import (
"context"
"fmt"

"time"

"github.com/buildkite/agent/v3/api"
"github.com/buildkite/agent/v3/logger"
"github.com/buildkite/roko"
"github.com/urfave/cli"
)

const buildCancelDescription = `Usage:
buildkite-agent build cancel [options...]
Description:
Cancel a running build.
Example:
$ buildkite-agent build cancel --build "1234"`

type BuildCancelConfig struct {
Build string `cli:"build" validate:"required"`

// Global flags
Debug bool `cli:"debug"`
LogLevel string `cli:"log-level"`
NoColor bool `cli:"no-color"`
Experiments []string `cli:"experiment" normalize:"list"`
Profile string `cli:"profile"`

// API config
DebugHTTP bool `cli:"debug-http"`
AgentAccessToken string `cli:"agent-access-token" validate:"required"`
Endpoint string `cli:"endpoint" validate:"required"`
NoHTTP2 bool `cli:"no-http2"`
}

var BuildCancelCommand = cli.Command{
Name: "cancel",
Usage: "Cancel a build",
Description: buildCancelDescription,
Flags: []cli.Flag{
cli.StringFlag{
Name: "build",
Value: "",
Usage: "The build to cancel",
EnvVar: "BUILDKITE_BUILD_ID",
},

// API Flags
AgentAccessTokenFlag,
EndpointFlag,
NoHTTP2Flag,
DebugHTTPFlag,

// Global flags
NoColorFlag,
DebugFlag,
LogLevelFlag,
ExperimentsFlag,
ProfileFlag,
},
Action: func(c *cli.Context) error {
ctx := context.Background()
ctx, cfg, l, _, done := setupLoggerAndConfig[BuildCancelConfig](ctx, c)
defer done()

if err := cancelBuild(ctx, cfg, l); err != nil {
return err
}

return nil
},
}

func cancelBuild(ctx context.Context, cfg BuildCancelConfig, l logger.Logger) error {
// Create the API client
client := api.NewClient(l, loadAPIClientConfig(cfg, "AgentAccessToken"))

// Retry the build cancellation a few times before giving up
if err := roko.NewRetrier(
roko.WithMaxAttempts(5),
roko.WithStrategy(roko.Constant(1*time.Second)),
roko.WithJitter(),
).DoWithContext(ctx, func(r *roko.Retrier) error {
// Attempt to cancel the build
build, resp, err := client.CancelBuild(ctx, cfg.Build)

// Don't bother retrying if the response was one of these statuses
if resp != nil && (resp.StatusCode == 401 || resp.StatusCode == 404 || resp.StatusCode == 400) {
r.Break()
return err
}

// Show the unexpected error
if err != nil {
l.Warn("%s (%s)", err, r)
return err
}

l.Info("Successfully cancelled build %s", build.ID)
return nil
}); err != nil {
return fmt.Errorf("failed to cancel build: %w", err)
}

return nil
}
51 changes: 51 additions & 0 deletions clicommand/build_cancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package clicommand

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/buildkite/agent/v3/logger"
"github.com/stretchr/testify/assert"
)

func TestBuildCancel(t *testing.T) {
t.Parallel()
ctx := context.Background()

t.Run("success", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"status": "canceled", "id": "1"}`))
}))

cfg := BuildCancelConfig{
Build: "1",
AgentAccessToken: "agentaccesstoken",
Endpoint: server.URL,
}

l := logger.NewBuffer()
err := cancelBuild(ctx, cfg, l)
assert.Nil(t, err)
assert.Contains(t, l.Messages, fmt.Sprintf("[info] Successfully cancelled build %s", cfg.Build))
})

t.Run("failed", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusInternalServerError)
}))

cfg := BuildCancelConfig{
Build: "1",
AgentAccessToken: "agentaccesstoken",
Endpoint: server.URL,
}

l := logger.NewBuffer()
err := cancelBuild(ctx, cfg, l)
assert.NotNil(t, err)
})
}
7 changes: 7 additions & 0 deletions clicommand/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ var BuildkiteAgentCommands = []cli.Command{
ArtifactShasumCommand,
},
},
{
Name: "build",
Usage: "Interact with a Buildkite build",
Subcommands: []cli.Command{
BuildCancelCommand,
},
},
{
Name: "env",
Usage: "Process environment subcommands",
Expand Down
1 change: 1 addition & 0 deletions clicommand/config_completeness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ var commandConfigPairs = []configCommandPair{
{Config: ArtifactSearchConfig{}, Command: ArtifactSearchCommand},
{Config: ArtifactShasumConfig{}, Command: ArtifactShasumCommand},
{Config: ArtifactUploadConfig{}, Command: ArtifactUploadCommand},
{Config: BuildCancelConfig{}, Command: BuildCancelCommand},
{Config: BootstrapConfig{}, Command: BootstrapCommand},
{Config: EnvDumpConfig{}, Command: EnvDumpCommand},
{Config: EnvGetConfig{}, Command: EnvGetCommand},
Expand Down

0 comments on commit 5ad9725

Please sign in to comment.