Stitch any CI into Tangled
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}