From a27c4b463838e8992b3a136a8b765eb7ce7184da Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Wed, 28 Aug 2024 12:08:54 +1000 Subject: [PATCH] Add command for canceling a running a build --- agent/api.go | 1 + api/builds.go | 28 ++++++ clicommand/build_cancel.go | 114 +++++++++++++++++++++++++ clicommand/build_cancel_test.go | 51 +++++++++++ clicommand/commands.go | 7 ++ clicommand/config_completeness_test.go | 1 + 6 files changed, 202 insertions(+) create mode 100644 api/builds.go create mode 100644 clicommand/build_cancel.go create mode 100644 clicommand/build_cancel_test.go diff --git a/agent/api.go b/agent/api.go index 422cec6a84..a5438036f4 100644 --- a/agent/api.go +++ b/agent/api.go @@ -13,6 +13,7 @@ type APIClient interface { AcquireJob(context.Context, string, ...api.Header) (*api.Job, *api.Response, error) Annotate(context.Context, string, *api.Annotation) (*api.Response, error) AnnotationRemove(context.Context, string, string) (*api.Response, error) + CancelBuild(context.Context, string) (*api.Build, *api.Response, error) Config() api.Config Connect(context.Context) (*api.Response, error) CreateArtifacts(context.Context, string, *api.ArtifactBatch) (*api.ArtifactBatchCreateResponse, *api.Response, error) diff --git a/api/builds.go b/api/builds.go new file mode 100644 index 0000000000..d3f1ea0929 --- /dev/null +++ b/api/builds.go @@ -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 +} diff --git a/clicommand/build_cancel.go b/clicommand/build_cancel.go new file mode 100644 index 0000000000..8e7c085345 --- /dev/null +++ b/clicommand/build_cancel.go @@ -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: + + # Cancels the current build + $ buildkite-agent build cancel + + # Cancel a different build + $ buildkite-agent build cancel --build "01234567-89ab-cdef-0123-456789abcdef"` + +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 UUID 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() + + return cancelBuild(ctx, cfg, l) + }, +} + +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 +} diff --git a/clicommand/build_cancel_test.go b/clicommand/build_cancel_test.go new file mode 100644 index 0000000000..8baef5ea43 --- /dev/null +++ b/clicommand/build_cancel_test.go @@ -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) + }) +} diff --git a/clicommand/commands.go b/clicommand/commands.go index 6c1825bfbb..e9013388c6 100644 --- a/clicommand/commands.go +++ b/clicommand/commands.go @@ -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", diff --git a/clicommand/config_completeness_test.go b/clicommand/config_completeness_test.go index 2ca01fa293..439f3969b8 100644 --- a/clicommand/config_completeness_test.go +++ b/clicommand/config_completeness_test.go @@ -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},