Skip to content

Commit 898101a

Browse files
pdaampcode-com
andcommitted
bktec upload: e.g. bktec upload test.xml
Amp-Thread-ID: https://ampcode.com/threads/T-019dd7a1-d422-717e-aac1-a48f10021895 Co-authored-by: Amp <amp@ampcode.com>
1 parent 3696b75 commit 898101a

5 files changed

Lines changed: 532 additions & 0 deletions

File tree

cli.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,12 @@ var cliCommand = &cli.Command{
563563
},
564564
},
565565
},
566+
{
567+
Name: "upload",
568+
Usage: "Upload test results to Test Engine",
569+
ArgsUsage: "<path-to-junit.xml-or-results.json>",
570+
Action: uploadAction,
571+
},
566572
{
567573
Name: "tools",
568574
Usage: "Utility tools",

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99

1010
require (
1111
drjosh.dev/zzglob v0.4.3
12+
github.com/google/uuid v1.6.0
1213
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
1314
github.com/olekukonko/tablewriter v0.0.5
1415
github.com/pact-foundation/pact-go/v2 v2.4.2

internal/upload/upload.go

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package upload
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"log/slog"
11+
"maps"
12+
"mime/multipart"
13+
"net/http"
14+
"os"
15+
"path/filepath"
16+
17+
"github.com/buildkite/test-engine-client/internal/version"
18+
"github.com/google/uuid"
19+
)
20+
21+
// Env abstracts environment variable access so it can be replaced for tests.
22+
type Env interface {
23+
Get(key string) string
24+
Lookup(key string) (string, bool)
25+
}
26+
27+
// OS is an Env backed by the real operating system environment.
28+
type OS struct{}
29+
30+
func (OS) Get(key string) string { return os.Getenv(key) }
31+
func (OS) Lookup(key string) (string, bool) { return os.LookupEnv(key) }
32+
33+
// Map is an Env backed by a map[string]string for testing.
34+
type Map map[string]string
35+
36+
func (m Map) Get(key string) string { return m[key] }
37+
func (m Map) Lookup(key string) (string, bool) {
38+
v, ok := m[key]
39+
return v, ok
40+
}
41+
42+
type RunEnvMap map[string]string
43+
44+
// Config is upload-specific configuration, but may also contain configuration
45+
// that is redundant with config.Config, since package upload isn't really
46+
// unified/integrated with the rest of bktec yet.
47+
type Config struct {
48+
// UploadUrl is the Test Engine upload API endpoint e.g. https://analytics-api.buildkite.com/v1/uploads
49+
UploadUrl string
50+
51+
// SuiteToken is the Test Engine upload API suite authentication token
52+
SuiteToken string
53+
}
54+
55+
func ConfigFromEnv(env Env) (Config, error) {
56+
url := env.Get("BUILDKITE_TEST_ENGINE_UPLOAD_URL")
57+
if url == "" {
58+
url = "https://analytics-api.buildkite.com/v1/uploads"
59+
}
60+
61+
token := env.Get("BUILDKITE_ANALYTICS_TOKEN")
62+
if token == "" {
63+
return Config{}, fmt.Errorf("BUILDKITE_ANALYTICS_TOKEN missing")
64+
}
65+
66+
return Config{
67+
UploadUrl: url,
68+
SuiteToken: token,
69+
}, nil
70+
}
71+
72+
// UploadFile uploads the given test results file to Test Engine, deriving
73+
// configuration and run-env metadata from env.
74+
func UploadFile(ctx context.Context, env Env, filename string) error {
75+
cfg, err := ConfigFromEnv(env)
76+
if err != nil {
77+
return fmt.Errorf("configuration error: %w", err)
78+
}
79+
80+
if filename == "" {
81+
return fmt.Errorf("expected path to JUnit XML or JSON file")
82+
}
83+
84+
info, err := os.Stat(filename)
85+
if err != nil {
86+
return fmt.Errorf("file does not exist: %s", filename)
87+
} else if !info.Mode().IsRegular() {
88+
return fmt.Errorf("not a regular file: %s", filename)
89+
}
90+
91+
var format string
92+
switch filepath.Ext(filename) {
93+
case ".xml":
94+
format = "junit"
95+
case ".json":
96+
format = "json"
97+
default:
98+
return fmt.Errorf("could not infer format (JUnit / JSON) from filename")
99+
}
100+
101+
runEnv, err := RunEnvFromEnv(env)
102+
if err != nil {
103+
return fmt.Errorf("unable to derive runEnv: %w", err)
104+
}
105+
106+
slog.Info("Uploading", "key", runEnv["key"], "format", format, "filename", filename)
107+
108+
respData, err := Upload(ctx, cfg, runEnv, format, filename)
109+
if err != nil {
110+
return err
111+
}
112+
113+
slog.Info("Upload successful", "url", respData["upload_url"])
114+
115+
return nil
116+
}
117+
118+
// Upload sends test result data to Test Engine.
119+
func Upload(ctx context.Context, cfg Config, runEnv RunEnvMap, format string, filename string) (map[string]string, error) {
120+
body, err := buildUploadData(runEnv, format, filename)
121+
if err != nil {
122+
return nil, fmt.Errorf("preparing upload data: %w", err)
123+
}
124+
125+
req, err := http.NewRequestWithContext(
126+
ctx,
127+
http.MethodPost,
128+
cfg.UploadUrl,
129+
body.buf,
130+
)
131+
if err != nil {
132+
return nil, fmt.Errorf("creating HTTP request: %w", err)
133+
}
134+
135+
req.Header.Set("Content-Type", body.writer.FormDataContentType())
136+
req.Header.Set("Authorization", fmt.Sprintf(`Token token="%s"`, cfg.SuiteToken))
137+
138+
resp, err := http.DefaultClient.Do(req)
139+
if err != nil {
140+
return nil, fmt.Errorf("HTTP error: %w", err)
141+
}
142+
defer resp.Body.Close()
143+
144+
status := resp.Status
145+
146+
// Currently this should get HTTP 202 Accepted, but let's be a bit permissive to future changes.
147+
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
148+
return nil, fmt.Errorf(
149+
"expected HTTP %d or %d from Upload API, got %s",
150+
http.StatusCreated,
151+
http.StatusAccepted,
152+
status,
153+
)
154+
}
155+
156+
// try to parse the response, but just warn if that fails
157+
respData := make(map[string]string)
158+
err = json.NewDecoder(resp.Body).Decode(&respData)
159+
if err != nil && !errors.Is(err, io.EOF) {
160+
slog.Warn("failed to parse response", "status", status, "error", err)
161+
}
162+
163+
return respData, nil
164+
}
165+
166+
func RunEnvFromEnv(env Env) (RunEnvMap, error) {
167+
runEnv := RunEnvMap{
168+
"collector": "bktec",
169+
"version": version.Version,
170+
}
171+
172+
if _, ok := env.Lookup("BUILDKITE_BUILD_ID"); ok {
173+
maps.Copy(runEnv, RunEnvMap{
174+
"CI": "buildkite",
175+
"branch": env.Get("BUILDKITE_BRANCH"),
176+
"commit_sha": env.Get("BUILDKITE_COMMIT"),
177+
"job_id": env.Get("BUILDKITE_JOB_ID"),
178+
"key": env.Get("BUILDKITE_BUILD_ID"),
179+
"message": env.Get("BUILDKITE_MESSAGE"),
180+
"number": env.Get("BUILDKITE_BUILD_NUMBER"),
181+
"url": env.Get("BUILDKITE_BUILD_URL"),
182+
})
183+
} else {
184+
key, err := uuid.NewV7()
185+
if err != nil {
186+
return nil, fmt.Errorf("UUID generation failed; broken PRNG? %w", err)
187+
}
188+
maps.Copy(runEnv, RunEnvMap{
189+
"CI": "generic",
190+
"key": key.String(),
191+
})
192+
}
193+
return runEnv, nil
194+
}
195+
196+
func buildUploadData(runEnv RunEnvMap, format string, filename string) (*MultipartBody, error) {
197+
var err error
198+
199+
file, err := os.Open(filename)
200+
if err != nil {
201+
return nil, fmt.Errorf("opening %s for reading: %w", filename, err)
202+
}
203+
defer file.Close()
204+
205+
body := NewMultipartBody()
206+
207+
if err = body.WriteFormat(format); err != nil {
208+
return nil, err
209+
}
210+
211+
if err = body.WriteRunEnv(runEnv); err != nil {
212+
return nil, err
213+
}
214+
215+
if err = body.WriteDataFromFile(file); err != nil {
216+
return nil, err
217+
}
218+
219+
if err = body.Close(); err != nil {
220+
return nil, err
221+
}
222+
223+
return body, nil
224+
}
225+
226+
type MultipartBody struct {
227+
writer multipart.Writer
228+
buf *bytes.Buffer
229+
}
230+
231+
func NewMultipartBody() *MultipartBody {
232+
buf := &bytes.Buffer{}
233+
return &MultipartBody{
234+
writer: *multipart.NewWriter(buf),
235+
buf: buf,
236+
}
237+
}
238+
239+
func (b *MultipartBody) WriteFormat(format string) error {
240+
return b.writer.WriteField("format", format)
241+
}
242+
243+
func (b *MultipartBody) WriteRunEnv(runEnv RunEnvMap) error {
244+
for k, v := range runEnv {
245+
if err := b.writer.WriteField("run_env["+k+"]", v); err != nil {
246+
return err
247+
}
248+
}
249+
return nil
250+
}
251+
252+
func (b *MultipartBody) WriteDataFromFile(file *os.File) error {
253+
part, err := b.writer.CreateFormFile("data", file.Name())
254+
if err != nil {
255+
return fmt.Errorf("MultipartBody: %w", err)
256+
}
257+
_, err = io.Copy(part, file)
258+
return err
259+
}
260+
261+
func (b *MultipartBody) Close() error {
262+
return b.writer.Close()
263+
}

0 commit comments

Comments
 (0)