Skip to content
Open
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 AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Go CLI application with main packages:
- Formatting with `gofumpt` in extra mode: `go tool gofumpt -extra -w .`
- Struct-based configuration patterns (e.g., `AgentWorkerConfig`, `JobRunnerConfig`)
- Context-aware functions: `func Name(ctx context.Context, ...)`
- Import organization: stdlib, external deps, internal packages
- Import organization: stdlib, then everything else (gofumpt groups all non-stdlib imports together)
- Error handling: explicit errors, wrapped with context
- Naming: PascalCase for exported, camelCase for private, ALL_CAPS for constants
- Interface types end with -er suffix where appropriate
Expand Down
18 changes: 18 additions & 0 deletions api/meta_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ type MetaDataExists struct {
Exists bool `json:"exists"`
}

// MetaDataBatch represents a batch of key/value pairs for the set-batch endpoint.
type MetaDataBatch struct {
Items []MetaData `json:"items"`
}

// Sets the meta data value
func (c *Client) SetMetaData(ctx context.Context, jobId string, metaData *MetaData) (*Response, error) {
u := fmt.Sprintf("jobs/%s/data/set", railsPathEscape(jobId))
Expand All @@ -30,6 +35,19 @@ func (c *Client) SetMetaData(ctx context.Context, jobId string, metaData *MetaDa
return c.doRequest(req, nil)
}

// SetMetaDataBatch sets multiple meta data key/value pairs in a single request.
// The operation is transactional: all items succeed or none do.
func (c *Client) SetMetaDataBatch(ctx context.Context, jobId string, batch *MetaDataBatch) (*Response, error) {
u := fmt.Sprintf("jobs/%s/data/set-batch", railsPathEscape(jobId))

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

return c.doRequest(req, nil)
}

// Gets the meta data value
func (c *Client) GetMetaData(ctx context.Context, scope, id, key string) (*MetaData, *Response, error) {
if scope != "job" && scope != "build" {
Expand Down
3 changes: 3 additions & 0 deletions clicommand/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ go_library(
"meta_data_get.go",
"meta_data_keys.go",
"meta_data_set.go",
"meta_data_set_batch.go",
"oidc_request_token.go",
"pipeline_upload.go",
"profiler.go",
Expand Down Expand Up @@ -118,13 +119,15 @@ go_test(
"artifact_shasum_test.go",
"build_cancel_test.go",
"config_completeness_test.go",
"meta_data_set_batch_test.go",
"git_credentials_helper_test.go",
"pipeline_upload_test.go",
"redactor_add_test.go",
"step_cancel_test.go",
],
embed = [":clicommand"],
deps = [
"//api",
"//core",
"//env",
"//internal/experiments",
Expand Down
1 change: 1 addition & 0 deletions clicommand/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ var BuildkiteAgentCommands = []cli.Command{
Usage: "Get/set metadata from Buildkite jobs",
Subcommands: []cli.Command{
MetaDataSetCommand,
MetaDataSetBatchCommand,
MetaDataGetCommand,
MetaDataExistsCommand,
MetaDataKeysCommand,
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 @@ -45,6 +45,7 @@ var commandConfigPairs = []configCommandPair{
{Config: MetaDataGetConfig{}, Command: MetaDataGetCommand},
{Config: MetaDataKeysConfig{}, Command: MetaDataKeysCommand},
{Config: MetaDataSetConfig{}, Command: MetaDataSetCommand},
{Config: MetaDataSetBatchConfig{}, Command: MetaDataSetBatchCommand},
{Config: OIDCTokenConfig{}, Command: OIDCRequestTokenCommand},
{Config: PipelineUploadConfig{}, Command: PipelineUploadCommand},
{Config: RedactorAddConfig{}, Command: RedactorAddCommand},
Expand Down
129 changes: 129 additions & 0 deletions clicommand/meta_data_set_batch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package clicommand

import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"

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

const metaDataSetBatchHelpDescription = `Usage:

buildkite-agent meta-data set-batch <key=value>... [options...]

Description:

Set multiple meta-data key/value pairs on a build in a single request.

Each argument must be in key=value format.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we support JSON input (over stdin)?

Copy link
Copy Markdown
Member Author

@pda pda Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe!
Do you think that would change the CLI design? 🤔

Would it look like one/some of these…

buildkite-agent meta-data set-batch < meta-data.json

buildkite-agent meta-data set-batch --json < meta-data.json

buildkite-agent meta-data set-batch --from-file meta-data.json

I mostly didn't add it because it contained more decisions 😅

If we think the current argv approach needs to change to make room for JSON/stdin input, I'll revise it. But if we think there's room at add it later, I'd rather defer it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(In my initial use-case, I have a bash script coordinating some build concerns, and it's rounding up lots of information and then setting ~8 meta-data key=values. I could push that into JSON structure, perhaps using jq as a builder, but it's a much better fit to just build an array of key=value strings and put them on argv)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I foresee it being useful if something other than Bash is orchestrating. We have JSON input in some other commands. But I agree it can be done later.


Keys and values must be non-empty strings, and strings containing only
whitespace characters are not allowed.

Example:

$ buildkite-agent meta-data set-batch foo=bar "greeting=hello world"
$ buildkite-agent meta-data set-batch duration:spec/a.rb=2.341 duration:spec/b.rb=5.672`

type MetaDataSetBatchConfig struct {
GlobalConfig
APIConfig

Job string `cli:"job" validate:"required"`
RedactedVars []string `cli:"redacted-vars" normalize:"list"`
}

var MetaDataSetBatchCommand = cli.Command{
Name: "set-batch",
Usage: "Set multiple meta-data key/value pairs on a build",
Description: metaDataSetBatchHelpDescription,
Flags: slices.Concat(globalFlags(), apiFlags(), []cli.Flag{
cli.StringFlag{
Name: "job",
Value: "",
Usage: "Which job's build should the meta-data be set on",
EnvVar: "BUILDKITE_JOB_ID",
},
RedactedVars,
}),
Action: func(c *cli.Context) error {
ctx := context.Background()
ctx, cfg, l, _, done := setupLoggerAndConfig[MetaDataSetBatchConfig](ctx, c)
defer done()

args := c.Args()
if len(args) == 0 {
return errors.New("at least one key=value argument is required")
}

items, err := parseMetaDataBatchArgs(args)
if err != nil {
return err
}

needles, _, err := redact.NeedlesFromEnv(cfg.RedactedVars)
if err != nil {
return err
}

for i := range items {
if redactedValue := redact.String(items[i].Value, needles); redactedValue != items[i].Value {
l.Warn("Meta-data value for key %q contained one or more secrets from environment variables that have been redacted. If this is deliberate, pass --redacted-vars='' or a list of patterns that does not match the variable containing the secret", items[i].Key)
items[i].Value = redactedValue
}
}

return setMetaDataBatch(ctx, cfg, l, items)
},
}

func parseMetaDataBatchArgs(args []string) ([]api.MetaData, error) {
items := make([]api.MetaData, 0, len(args))
for _, arg := range args {
key, value, ok := strings.Cut(arg, "=")
if !ok {
return nil, fmt.Errorf("invalid argument %q: must be in key=value format", arg)
}
if strings.TrimSpace(key) == "" {
return nil, fmt.Errorf("invalid argument %q: key cannot be empty, or composed of only whitespace characters", arg)
}
if strings.TrimSpace(value) == "" {
return nil, fmt.Errorf("invalid argument %q: value cannot be empty, or composed of only whitespace characters", arg)
}
Comment on lines +98 to +100
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we offer a way to "clear" a particular key? key= (nothing following) seems like a reasonable syntax for that.

items = append(items, api.MetaData{Key: key, Value: value})
}
return items, nil
}

func setMetaDataBatch(ctx context.Context, cfg MetaDataSetBatchConfig, l logger.Logger, items []api.MetaData) error {
client := api.NewClient(l, loadAPIClientConfig(cfg, "AgentAccessToken"))

batch := &api.MetaDataBatch{Items: items}

if err := roko.NewRetrier(
roko.WithMaxAttempts(10),
roko.WithStrategy(roko.ExponentialSubsecond(2*time.Second)),
).DoWithContext(ctx, func(r *roko.Retrier) error {
resp, err := client.SetMetaDataBatch(ctx, cfg.Job, batch)
if resp != nil && (resp.StatusCode == 401 || resp.StatusCode == 404) {
r.Break()
Comment on lines +116 to +117
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop retrying unrecoverable 4xx batch validation errors

In setMetaDataBatch, retries are only disabled for 401/404, so other deterministic client-side failures (for example a 422 validation error from POST /jobs/:id/data/set-batch) are retried up to 10 times with exponential backoff before returning. That turns immediate user/input errors into long waits and makes this command appear hung for cases that can never succeed on retry; this path should break retries for unrecoverable 4xx responses (at least 400/422, while preserving retry for transient statuses like 429 if desired).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Valid observation, but this intentionally matches the existing meta-data set command, which has the same retry behaviour (only breaks on 401/404). Broadening the no-retry set for 4xx could be a good improvement, but it should be done consistently across both commands.

}
if err != nil {
l.Warn("%s (%s)", err, r)
return err
}
return nil
}); err != nil {
return fmt.Errorf("failed to set meta-data batch: %w", err)
}

return nil
}
Loading