Skip to content

Commit a1ce56d

Browse files
committed
bktec upload: e.g. bktec upload test.xml
1 parent 1070314 commit a1ce56d

File tree

5 files changed

+510
-1
lines changed

5 files changed

+510
-1
lines changed

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,17 @@ require (
1111

1212
require (
1313
drjosh.dev/zzglob v0.4.0
14+
github.com/google/uuid v1.6.0
1415
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
1516
github.com/olekukonko/tablewriter v0.0.5
1617
github.com/pact-foundation/pact-go/v2 v2.0.10
18+
golang.org/x/net v0.33.0
1719
golang.org/x/sys v0.30.0
1820
)
1921

2022
require (
2123
github.com/hashicorp/logutils v1.0.0 // indirect
2224
github.com/mattn/go-runewidth v0.0.9 // indirect
23-
golang.org/x/net v0.33.0 // indirect
2425
golang.org/x/text v0.21.0 // indirect
2526
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
2627
google.golang.org/grpc v1.67.3 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
66
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
77
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
88
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
9+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
10+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
911
github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y=
1012
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
1113
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=

internal/upload/upload.go

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

0 commit comments

Comments
 (0)