From 9799810e8aa3180cee9a198d75157dfd1502e318 Mon Sep 17 00:00:00 2001 From: Mitch Smith Date: Fri, 8 Nov 2024 11:46:27 +1000 Subject: [PATCH] Create `buildkite-agent step cancel --step "key"` subcommand --- agent/api.go | 1 + api/steps.go | 26 ++++++ clicommand/commands.go | 3 +- clicommand/config_completeness_test.go | 1 + clicommand/step_cancel.go | 113 +++++++++++++++++++++++++ clicommand/step_cancel_test.go | 52 ++++++++++++ 6 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 clicommand/step_cancel.go create mode 100644 clicommand/step_cancel_test.go diff --git a/agent/api.go b/agent/api.go index 85964bd12a..283da528fe 100644 --- a/agent/api.go +++ b/agent/api.go @@ -36,6 +36,7 @@ type APIClient interface { SearchArtifacts(context.Context, string, *api.ArtifactSearchOptions) ([]*api.Artifact, *api.Response, error) SetMetaData(context.Context, string, *api.MetaData) (*api.Response, error) StartJob(context.Context, *api.Job) (*api.Response, error) + StepCancel(context.Context, string, *api.StepCancel) (*api.StepCancelResponse, *api.Response, error) StepExport(context.Context, string, *api.StepExportRequest) (*api.StepExportResponse, *api.Response, error) StepUpdate(context.Context, string, *api.StepUpdate) (*api.Response, error) UpdateArtifacts(context.Context, string, []api.ArtifactState) (*api.Response, error) diff --git a/api/steps.go b/api/steps.go index b8ab745e79..ed8b2f500d 100644 --- a/api/steps.go +++ b/api/steps.go @@ -54,3 +54,29 @@ func (c *Client) StepUpdate(ctx context.Context, stepIdOrKey string, stepUpdate return c.doRequest(req, nil) } + +type StepCancel struct { + Force bool `json:"force,omitempty"` +} + +type StepCancelResponse struct { + UUID string `json:"uuid"` +} + +// StepCancel cancels a step +func (c *Client) StepCancel(ctx context.Context, stepIdOrKey string, stepCancel *StepCancel) (*StepCancelResponse, *Response, error) { + u := fmt.Sprintf("steps/%s/cancel", railsPathEscape(stepIdOrKey)) + + req, err := c.newRequest(ctx, "POST", u, stepCancel) + if err != nil { + return nil, nil, err + } + + stepCancelResponse := new(StepCancelResponse) + resp, err := c.doRequest(req, stepCancelResponse) + if err != nil { + return nil, resp, err + } + + return stepCancelResponse, resp, nil +} diff --git a/clicommand/commands.go b/clicommand/commands.go index e9013388c6..81e8e57e06 100644 --- a/clicommand/commands.go +++ b/clicommand/commands.go @@ -96,10 +96,11 @@ var BuildkiteAgentCommands = []cli.Command{ }, { Name: "step", - Usage: "Get or update an attribute of a build step", + Usage: "Get or update an attribute of a build step, or cancel unfinished jobs for a step", Subcommands: []cli.Command{ StepGetCommand, StepUpdateCommand, + StepCancelCommand, }, }, { diff --git a/clicommand/config_completeness_test.go b/clicommand/config_completeness_test.go index 439f3969b8..fbd53f1a3f 100644 --- a/clicommand/config_completeness_test.go +++ b/clicommand/config_completeness_test.go @@ -42,6 +42,7 @@ var commandConfigPairs = []configCommandPair{ {Config: PipelineUploadConfig{}, Command: PipelineUploadCommand}, {Config: RedactorAddConfig{}, Command: RedactorAddCommand}, {Config: SecretGetConfig{}, Command: SecretGetCommand}, + {Config: StepCancelConfig{}, Command: StepCancelCommand}, {Config: StepGetConfig{}, Command: StepGetCommand}, {Config: StepUpdateConfig{}, Command: StepUpdateCommand}, {Config: ToolKeygenConfig{}, Command: ToolKeygenCommand}, diff --git a/clicommand/step_cancel.go b/clicommand/step_cancel.go new file mode 100644 index 0000000000..a84c1d3ca9 --- /dev/null +++ b/clicommand/step_cancel.go @@ -0,0 +1,113 @@ +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 stepCancelHelpDescription = `Usage: + + buildkite-agent step cancel [options...] + +Description: + +Cancel all unfinished jobs for a step + +Example: + + $ buildkite-agent step cancel --step "key" + $ buildkite-agent step cancel --step "key" --force` + +type StepCancelConfig struct { + StepOrKey string `cli:"step" validate:"required"` + Force bool `cli:"force"` + + // 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 StepCancelCommand = cli.Command{ + Name: "cancel", + Usage: "Cancel all unfinished jobs for a step", + Description: stepCancelHelpDescription, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "step", + Value: "", + Usage: "The step to cancel. Can be either its ID (BUILDKITE_STEP_ID) or key (BUILDKITE_STEP_KEY)", + EnvVar: "BUILDKITE_STEP_ID", + }, + cli.BoolFlag{ + Name: "force", + Usage: "Don't wait for the agent to finish before cancelling the jobs", + EnvVar: "BUILDKITE_STEP_CANCEL_FORCE", + }, + + // API Flags + AgentAccessTokenFlag, + EndpointFlag, + NoHTTP2Flag, + DebugHTTPFlag, + + // Global flags + NoColorFlag, + DebugFlag, + LogLevelFlag, + ExperimentsFlag, + ProfileFlag, + }, + Action: func(c *cli.Context) error { + ctx, cfg, l, _, done := setupLoggerAndConfig[StepCancelConfig](context.Background(), c) + defer done() + + return cancelStep(ctx, cfg, l) + }, +} + +func cancelStep(ctx context.Context, cfg StepCancelConfig, l logger.Logger) error { + // Create the API client + client := api.NewClient(l, loadAPIClientConfig(cfg, "AgentAccessToken")) + + // Create the value to cancel + cancel := &api.StepCancel{ + Force: cfg.Force, + } + + // Post the change + if err := roko.NewRetrier( + roko.WithMaxAttempts(10), + roko.WithStrategy(roko.Constant(5*time.Second)), + ).DoWithContext(ctx, func(r *roko.Retrier) error { + stepCancelResponse, resp, err := client.StepCancel(ctx, cfg.StepOrKey, cancel) + if resp != nil && (resp.StatusCode == 400 || resp.StatusCode == 401 || resp.StatusCode == 404) { + r.Break() + } + if err != nil { + l.Warn("%s (%s)", err, r) + return err + } + + l.Info("Successfully cancelled step: %s", stepCancelResponse.UUID) + return nil + }); err != nil { + return fmt.Errorf("Failed to cancel step: %w", err) + } + + return nil +} diff --git a/clicommand/step_cancel_test.go b/clicommand/step_cancel_test.go new file mode 100644 index 0000000000..3ec1f960e9 --- /dev/null +++ b/clicommand/step_cancel_test.go @@ -0,0 +1,52 @@ +package clicommand + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/buildkite/agent/v3/logger" + "github.com/stretchr/testify/assert" +) + +func TestStepCancel(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(`{"uuid": "b0db1550-e68c-428f-9b4d-edf5599b2cff"}`)) + })) + + cfg := StepCancelConfig{ + Force: true, + StepOrKey: "some-random-key", + AgentAccessToken: "agentaccesstoken", + Endpoint: server.URL, + } + + l := logger.NewBuffer() + err := cancelStep(ctx, cfg, l) + assert.Nil(t, err) + assert.Contains(t, l.Messages, "[info] Successfully cancelled step: b0db1550-e68c-428f-9b4d-edf5599b2cff") + }) + + t.Run("failed", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusInternalServerError) + })) + + cfg := StepCancelConfig{ + Force: true, + StepOrKey: "some-random-key", + AgentAccessToken: "agentaccesstoken", + Endpoint: server.URL, + } + + l := logger.NewBuffer() + err := cancelStep(ctx, cfg, l) + assert.Contains(t, err.Error(), "Failed to cancel step") + }) +}