Stitch any CI into Tangled
3

Configure Feed

Select the types of activity you want to include in your feed.

1// Package buildkite is a small Buildkite REST + webhook client tack 2// uses to drive its Buildkite-backed Provider implementation. 3// 4// The package deliberately covers a tiny slice of the upstream API 5// (create build, get build, fetch job log, decode + authenticate 6// webhook payloads). It exists as its own package so the rest of 7// tack — particularly the Provider implementation that translates 8// Tangled triggers into Buildkite builds — can stay focused on 9// translation rather than HTTP plumbing. 10// 11// Naming convention: types here are *not* prefixed with "Buildkite". 12// Imported as `buildkite.Client`, `buildkite.Build`, etc., the package 13// path supplies the disambiguation already. 14package buildkite 15 16import ( 17 "bytes" 18 "context" 19 "crypto/hmac" 20 "crypto/sha256" 21 "encoding/hex" 22 "encoding/json" 23 "errors" 24 "fmt" 25 "io" 26 "net/http" 27 "strconv" 28 "strings" 29 "time" 30) 31 32// APIBase is the public Buildkite REST API root. Exported as a var 33// (not a const) so tests can swap it for an httptest server URL 34// without hooking up a real Buildkite account. 35var APIBase = "https://api.buildkite.com" 36 37// ErrNotFound is returned by Get* methods when the upstream returns 38// 404. Callers translate it to whatever shape they need (the Provider 39// maps it onto its own ErrLogsNotFound for the /logs handler). 40var ErrNotFound = errors.New("buildkite: not found") 41 42// Client is a thin wrapper around net/http carrying API credentials. 43// Organisation slug is *not* held on the client — every call takes 44// its target org explicitly so a single client can address multiple 45// orgs the token has access to. Safe for concurrent use; the 46// embedded http.Client is goroutine-safe. 47type Client struct { 48 http *http.Client 49 token string 50} 51 52// NewClient builds a Client with sensible defaults. The 30s timeout 53// covers individual requests, not the whole client lifetime — 54// long-poll-style endpoints aren't used here, so a generous-but-bounded 55// per-request timeout is the right default. 56func NewClient(token string) *Client { 57 return &Client{ 58 http: &http.Client{Timeout: 30 * time.Second}, 59 token: token, 60 } 61} 62 63// Job is the subset of a Buildkite job object we care about: the ID 64// and name we need to fetch logs for it, plus its state for 65// surfacing in webhook handling. 66// 67// Buildkite jobs come in several "type" values (script, waiter, 68// manual) — only "script" jobs have logs, but we decode the slice 69// as-is and let the caller decide whether to skip non-script entries. 70type Job struct { 71 ID string `json:"id"` 72 Type string `json:"type"` 73 Name string `json:"name"` 74 State string `json:"state"` 75} 76 77// Build is the subset of a Buildkite build object we care about. 78// Fields not present here are dropped silently by the JSON decoder — 79// keep this list lean so additions to the upstream schema don't 80// force us to touch this file. 81type Build struct { 82 ID string `json:"id"` 83 Number int64 `json:"number"` 84 State string `json:"state"` 85 WebURL string `json:"web_url"` 86 Commit string `json:"commit"` 87 Branch string `json:"branch"` 88 Message string `json:"message"` 89 MetaData map[string]string `json:"meta_data"` 90 Jobs []Job `json:"jobs"` 91 Pipeline map[string]interface{} `json:"pipeline"` 92} 93 94// CreateBuildRequest is the request body for POST /builds. Only the 95// fields callers actively use are exposed — the upstream API accepts 96// many more, but we'd just be passing through dead options. 97// 98// IgnorePipelineBranchFilters / CleanCheckout default to false on the 99// wire (omitempty elides the zero value); callers that want them 100// turned on should set the field explicitly. 101// 102// PullRequestBaseBranch surfaces a Tangled PR trigger's target 103// branch so Buildkite step filters keyed on the PR base behave the 104// way users expect. 105type CreateBuildRequest struct { 106 Commit string `json:"commit"` 107 Branch string `json:"branch"` 108 Message string `json:"message,omitempty"` 109 Env map[string]string `json:"env,omitempty"` 110 MetaData map[string]string `json:"meta_data,omitempty"` 111 CleanCheckout bool `json:"clean_checkout,omitempty"` 112 IgnorePipelineBranchFilters bool `json:"ignore_pipeline_branch_filters,omitempty"` 113 PullRequestBaseBranch string `json:"pull_request_base_branch,omitempty"` 114} 115 116// CreateBuild fires a build on the named pipeline. Returns the 117// decoded response so the caller can persist build_uuid + number for 118// later webhook lookup. 119// 120// Buildkite returns 201 on success; anything else is wrapped into an 121// error that includes the response body so a misconfigured pipeline 122// (e.g. wrong slug, missing branch) surfaces useful diagnostics into 123// the caller's log. 124func (c *Client) CreateBuild( 125 ctx context.Context, 126 org string, 127 pipelineSlug string, 128 req CreateBuildRequest, 129) (*Build, error) { 130 body, err := json.Marshal(req) 131 if err != nil { 132 return nil, fmt.Errorf("marshal build request: %w", err) 133 } 134 135 url := fmt.Sprintf("%s/v2/organizations/%s/pipelines/%s/builds", 136 APIBase, org, pipelineSlug, 137 ) 138 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) 139 if err != nil { 140 return nil, fmt.Errorf("build request: %w", err) 141 } 142 httpReq.Header.Set("Authorization", "Bearer "+c.token) 143 httpReq.Header.Set("Content-Type", "application/json") 144 145 resp, err := c.http.Do(httpReq) 146 if err != nil { 147 return nil, fmt.Errorf("create build: %w", err) 148 } 149 defer resp.Body.Close() 150 151 if resp.StatusCode != http.StatusCreated { 152 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 153 return nil, fmt.Errorf("create build: status %d: %s", 154 resp.StatusCode, strings.TrimSpace(string(raw)), 155 ) 156 } 157 158 var out Build 159 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { 160 return nil, fmt.Errorf("decode build response: %w", err) 161 } 162 return &out, nil 163} 164 165// GetBuild fetches the full build record by number, including the 166// jobs slice. Used by callers that need the current set of jobs for 167// a known (pipelineSlug, buildNumber) pair. 168// 169// Returns ErrNotFound when Buildkite responds 404. 170func (c *Client) GetBuild( 171 ctx context.Context, 172 org string, 173 pipelineSlug string, 174 buildNumber int64, 175) (*Build, error) { 176 url := fmt.Sprintf("%s/v2/organizations/%s/pipelines/%s/builds/%d", 177 APIBase, org, pipelineSlug, buildNumber, 178 ) 179 httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 180 if err != nil { 181 return nil, fmt.Errorf("build request: %w", err) 182 } 183 httpReq.Header.Set("Authorization", "Bearer "+c.token) 184 185 resp, err := c.http.Do(httpReq) 186 if err != nil { 187 return nil, fmt.Errorf("get build: %w", err) 188 } 189 defer resp.Body.Close() 190 191 if resp.StatusCode == http.StatusNotFound { 192 return nil, ErrNotFound 193 } 194 if resp.StatusCode != http.StatusOK { 195 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 196 return nil, fmt.Errorf("get build: status %d: %s", 197 resp.StatusCode, strings.TrimSpace(string(raw)), 198 ) 199 } 200 201 var out Build 202 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { 203 return nil, fmt.Errorf("decode build: %w", err) 204 } 205 return &out, nil 206} 207 208// GetJobLog fetches the plain-text log for a single job. Buildkite 209// supports several formats; we ask for text/plain explicitly so the 210// response is one big string the caller can split on newlines. 211// 212// Returns ErrNotFound when Buildkite responds 404 (typical for a 213// job that hasn't started yet — it has no log to serve). 214func (c *Client) GetJobLog( 215 ctx context.Context, 216 org string, 217 pipelineSlug string, 218 buildNumber int64, 219 jobID string, 220) (string, error) { 221 url := fmt.Sprintf("%s/v2/organizations/%s/pipelines/%s/builds/%d/jobs/%s/log", 222 APIBase, org, pipelineSlug, buildNumber, jobID, 223 ) 224 httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 225 if err != nil { 226 return "", fmt.Errorf("build request: %w", err) 227 } 228 httpReq.Header.Set("Authorization", "Bearer "+c.token) 229 httpReq.Header.Set("Accept", "text/plain") 230 231 resp, err := c.http.Do(httpReq) 232 if err != nil { 233 return "", fmt.Errorf("get job log: %w", err) 234 } 235 defer resp.Body.Close() 236 237 if resp.StatusCode == http.StatusNotFound { 238 return "", ErrNotFound 239 } 240 if resp.StatusCode != http.StatusOK { 241 raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) 242 return "", fmt.Errorf("get job log: status %d: %s", 243 resp.StatusCode, strings.TrimSpace(string(raw)), 244 ) 245 } 246 247 body, err := io.ReadAll(resp.Body) 248 if err != nil { 249 return "", fmt.Errorf("read job log: %w", err) 250 } 251 return string(body), nil 252} 253 254// WebhookPayload is the small slice of the webhook body callers 255// actually decode. Build events all wrap a "build" object; job 256// events wrap a "job" — keeping both around lets callers add 257// job-level mapping later without changing the decoder. 258// 259// Organization is the top-level organization object Buildkite mirrors 260// onto every webhook. We decode just its slug so the provider can 261// reconstruct (org, pipeline_slug, build_number) from a webhook alone 262// when the local UUID→tuple mapping hasn't been persisted yet (the 263// race between CreateBuild returning and InsertBuildkiteBuild landing). 264type WebhookPayload struct { 265 Event string `json:"event"` 266 Build Build `json:"build"` 267 Job Job `json:"job"` 268 Organization Organization `json:"organization"` 269} 270 271// Organization is the small slice of Buildkite's organization object 272// we care about on webhook payloads: just the slug, used as the org 273// component of REST URLs in subsequent calls. 274type Organization struct { 275 Slug string `json:"slug"` 276} 277 278// WebhookMode selects how an inbound webhook request is 279// authenticated. The two values correspond directly to the two 280// settings Buildkite's notification service exposes for this: 281// WebhookModeToken sends the secret in the X-Buildkite-Token header 282// in plain text; WebhookModeSignature sends an HMAC-SHA256 of the 283// body in X-Buildkite-Signature. 284type WebhookMode string 285 286const ( 287 WebhookModeToken WebhookMode = "token" 288 WebhookModeSignature WebhookMode = "signature" 289) 290 291// MaxSignatureAge bounds how far the signed timestamp may be from 292// the local clock before VerifySignature rejects the request as 293// stale (or implausibly future-dated). Without this, an attacker who 294// captures one valid signed delivery can replay it indefinitely, 295// generating duplicate status events and unbounded growth in the 296// events table. Five minutes matches Buildkite's own published 297// guidance for verifying their signatures and is the same window 298// Stripe et al. use; it absorbs realistic clock skew while keeping 299// the replay surface tight. 300const MaxSignatureAge = 5 * time.Minute 301 302// timeNow is the clock VerifySignature consults for freshness. A 303// package-level var so tests can pin "now" deterministically without 304// touching the system clock or threading a clock argument through 305// every caller. 306var timeNow = time.Now 307 308// VerifySignature validates the X-Buildkite-Signature header against 309// secret using the documented "<timestamp>.<body>" HMAC-SHA256 310// scheme. Returns nil when the header is well-formed, the digest 311// matches, and the signed timestamp is within MaxSignatureAge of 312// now; any other condition returns an error. 313// 314// The header format is "timestamp=<unix>,signature=<hex>". 315func VerifySignature(header, secret string, body []byte) error { 316 if header == "" { 317 return errors.New("missing X-Buildkite-Signature header") 318 } 319 if secret == "" { 320 // A misconfigured server is a programmer bug, but we'd 321 // rather fail closed than silently accept any signature. 322 return errors.New("server has no webhook secret configured") 323 } 324 325 var ts, sig string 326 for _, part := range strings.Split(header, ",") { 327 k, v, ok := strings.Cut(strings.TrimSpace(part), "=") 328 if !ok { 329 continue 330 } 331 switch k { 332 case "timestamp": 333 ts = v 334 case "signature": 335 sig = v 336 } 337 } 338 if ts == "" || sig == "" { 339 return errors.New("malformed signature header") 340 } 341 tsInt, err := strconv.ParseInt(ts, 10, 64) 342 if err != nil { 343 return fmt.Errorf("invalid timestamp: %w", err) 344 } 345 // Freshness check: reject signatures whose timestamp is more 346 // than MaxSignatureAge away from now in either direction. The 347 // symmetric bound also rejects implausibly future-dated stamps, 348 // which would otherwise let an attacker mint a replay window 349 // well into the future. 350 skew := timeNow().Sub(time.Unix(tsInt, 0)) 351 if skew < 0 { 352 skew = -skew 353 } 354 if skew > MaxSignatureAge { 355 return fmt.Errorf("signature timestamp outside freshness window (skew %s)", skew) 356 } 357 358 mac := hmac.New(sha256.New, []byte(secret)) 359 mac.Write([]byte(ts)) 360 mac.Write([]byte(".")) 361 mac.Write(body) 362 expected := hex.EncodeToString(mac.Sum(nil)) 363 364 // Compare in constant time to keep the verifier from leaking 365 // the expected digest through timing. 366 if !hmac.Equal([]byte(expected), []byte(sig)) { 367 return errors.New("signature mismatch") 368 } 369 return nil 370} 371 372// VerifyToken handles the simpler X-Buildkite-Token mode: the 373// configured secret is sent verbatim in the header. Constant-time 374// comparison keeps a brute-forcing attacker from learning the token 375// one byte at a time. 376// 377// Returns nil on match. Two error cases: 378// - missing header on the request (caller should 401), 379// - server has no expected token configured (caller should 500; 380// fail-closed, never accept-anything). 381func VerifyToken(header, expected string) error { 382 if header == "" { 383 return errors.New("missing X-Buildkite-Token header") 384 } 385 if expected == "" { 386 return errors.New("server has no webhook token configured") 387 } 388 if !hmac.Equal([]byte(header), []byte(expected)) { 389 return errors.New("token mismatch") 390 } 391 return nil 392}