From a1ce56d4532e2b4f1d86b050300d48e03770a712 Mon Sep 17 00:00:00 2001 From: Paul Annesley Date: Tue, 11 Mar 2025 21:30:22 +1030 Subject: [PATCH] bktec upload: e.g. `bktec upload test.xml` --- go.mod | 3 +- go.sum | 2 + internal/upload/upload.go | 245 ++++++++++++++++++++++++++++++++ internal/upload/upload_test.go | 252 +++++++++++++++++++++++++++++++++ main.go | 9 ++ 5 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 internal/upload/upload.go create mode 100644 internal/upload/upload_test.go diff --git a/go.mod b/go.mod index 03c0406c..7d50048f 100644 --- a/go.mod +++ b/go.mod @@ -11,16 +11,17 @@ require ( require ( drjosh.dev/zzglob v0.4.0 + github.com/google/uuid v1.6.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/olekukonko/tablewriter v0.0.5 github.com/pact-foundation/pact-go/v2 v2.0.10 + golang.org/x/net v0.33.0 golang.org/x/sys v0.30.0 ) require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect - golang.org/x/net v0.33.0 // indirect golang.org/x/text v0.21.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect google.golang.org/grpc v1.67.3 // indirect diff --git a/go.sum b/go.sum index 3ee73105..a915ffc6 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= diff --git a/internal/upload/upload.go b/internal/upload/upload.go new file mode 100644 index 00000000..21f0b2bf --- /dev/null +++ b/internal/upload/upload.go @@ -0,0 +1,245 @@ +package upload + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "maps" + "mime/multipart" + "net/http" + "os" + "path/filepath" + + "github.com/buildkite/test-engine-client/internal/env" + "github.com/buildkite/test-engine-client/internal/version" + "github.com/google/uuid" + "golang.org/x/net/context" +) + +type RunEnvMap map[string]string + +// Config is upload-specific configuration, but may also contain configuration +// that is redundant with config.Config, since package upload isn't really +// unified/integrated with the rest of bktec yet. +type Config struct { + // UploadUrl is the Test Engine upload API endpoint e.g. https://analytics-api.buildkite.com/v1/uploads + UploadUrl string + + // SuiteToken is the Test Engine upload API suite authentication token + SuiteToken string +} + +func ConfigFromEnv(env env.Env) (Config, error) { + url := env.Get("BUILDKITE_TEST_ENGINE_UPLOAD_URL") + if url == "" { + url = "https://analytics-api.buildkite.com/v1/uploads" + } + + token := env.Get("BUILDKITE_ANALYTICS_TOKEN") + if token == "" { + return Config{}, fmt.Errorf("BUILDKITE_ANALYTICS_TOKEN missing") + } + + return Config{ + UploadUrl: url, + SuiteToken: token, + }, nil +} + +// UploadCLI is a CLI entrypoint for uploading results to Test Engine. +func UploadCLI(flag *flag.FlagSet, env env.Env) error { + cfg, err := ConfigFromEnv(env) + if err != nil { + return fmt.Errorf("configuration error: %w", err) + } + + filename := flag.Arg(1) + if filename == "" { + return fmt.Errorf("expected path to JUnit XML or JSON file") + } + + info, err := os.Stat(filename) + if err != nil { + return fmt.Errorf("file does not exist: %s", filename) + } else if !info.Mode().IsRegular() { + return fmt.Errorf("not a regular file: %s", filename) + } + + var format string + switch filepath.Ext(filename) { + case ".xml": + format = "junit" + case ".json": + format = "json" + default: + return fmt.Errorf("could not infer format (JUnit / JSON) from filename") + } + + runEnv, err := RunEnvFromEnv(env) + if err != nil { + return fmt.Errorf("unable to derive runEnv: %w", err) + } + + slog.Info("Uploading", "key", runEnv["key"], "format", format, "filename", filename) + + ctx := context.Background() + respData, err := Upload(ctx, cfg, runEnv, format, filename) + if err != nil { + return err + } + + slog.Info("Upload successful", "url", respData["upload_url"]) + + return nil +} + +// Upload sends test result data to Test Engine. +func Upload(ctx context.Context, cfg Config, runEnv RunEnvMap, format string, filename string) (map[string]string, error) { + body, err := buildUploadData(runEnv, format, filename) + if err != nil { + return nil, fmt.Errorf("preparing upload data: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + cfg.UploadUrl, + body.buf, + ) + if err != nil { + return nil, fmt.Errorf("creating HTTP request: %w", err) + } + + req.Header.Set("Content-Type", body.writer.FormDataContentType()) + req.Header.Set("Authorization", fmt.Sprintf(`Token token="%s"`, cfg.SuiteToken)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP error: %w", err) + } + defer resp.Body.Close() + + status := resp.Status + + // Currently this should get HTTP 202 Accepted, but let's be a bit permissive to future changes. + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted { + return nil, fmt.Errorf( + "expected HTTP %d or %d from Upload API, got %s", + http.StatusCreated, + http.StatusAccepted, + status, + ) + } + + // try to parse the response, but just warn if that fails + respData := make(map[string]string) + err = json.NewDecoder(resp.Body).Decode(&respData) + if err != nil && !errors.Is(err, io.EOF) { + slog.Warn("failed to parse response", "status", status, "error", err) + } + + return respData, nil +} + +func RunEnvFromEnv(env env.Env) (RunEnvMap, error) { + runEnv := RunEnvMap{ + "collector": "bktec", + "version": version.Version, + } + + if _, ok := env.Lookup("BUILDKITE_BUILD_ID"); ok { + maps.Copy(runEnv, RunEnvMap{ + "CI": "buildkite", + "branch": env.Get("BUILDKITE_BRANCH"), + "commit_sha": env.Get("BUILDKITE_COMMIT"), + "job_id": env.Get("BUILDKITE_JOB_ID"), + "key": env.Get("BUILDKITE_BUILD_ID"), + "message": env.Get("BUILDKITE_MESSAGE"), + "number": env.Get("BUILDKITE_BUILD_NUMBER"), + "url": env.Get("BUILDKITE_BUILD_URL"), + }) + } else { + key, err := uuid.NewV7() + if err != nil { + return nil, fmt.Errorf("UUID generation failed; broken PRNG? %w", err) + } + maps.Copy(runEnv, RunEnvMap{ + "CI": "generic", + "key": key.String(), + }) + } + return runEnv, nil +} + +func buildUploadData(runEnv RunEnvMap, format string, filename string) (*MultipartBody, error) { + var err error + + file, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("opening %s for reading: %w", filename, err) + } + defer file.Close() + + body := NewMultipartBody() + + if err = body.WriteFormat(format); err != nil { + return nil, err + } + + if err = body.WriteRunEnv(runEnv); err != nil { + return nil, err + } + + if err = body.WriteDataFromFile(file); err != nil { + return nil, err + } + + if err = body.Close(); err != nil { + return nil, err + } + + return body, nil +} + +type MultipartBody struct { + writer multipart.Writer + buf *bytes.Buffer +} + +func NewMultipartBody() *MultipartBody { + buf := &bytes.Buffer{} + return &MultipartBody{ + writer: *multipart.NewWriter(buf), + buf: buf, + } +} + +func (b *MultipartBody) WriteFormat(format string) error { + return b.writer.WriteField("format", format) +} + +func (b *MultipartBody) WriteRunEnv(runEnv RunEnvMap) error { + for k, v := range runEnv { + if err := b.writer.WriteField("run_env["+k+"]", v); err != nil { + return err + } + } + return nil +} + +func (b *MultipartBody) WriteDataFromFile(file *os.File) error { + part, err := b.writer.CreateFormFile("data", file.Name()) + if err != nil { + return fmt.Errorf("MultipartBody: %w", err) + } + _, err = io.Copy(part, file) + return err +} + +func (b *MultipartBody) Close() error { + return b.writer.Close() +} diff --git a/internal/upload/upload_test.go b/internal/upload/upload_test.go new file mode 100644 index 00000000..c774cf88 --- /dev/null +++ b/internal/upload/upload_test.go @@ -0,0 +1,252 @@ +package upload + +import ( + "context" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/buildkite/test-engine-client/internal/env" + "github.com/buildkite/test-engine-client/internal/version" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" +) + +func TestConfigFromEnv(t *testing.T) { + cfg, err := ConfigFromEnv(env.Map{ + "BUILDKITE_ANALYTICS_TOKEN": "hunter2", + }) + if err != nil { + t.Errorf("ConfigFromEnv(): %v", err) + } + + want := Config{ + UploadUrl: "https://analytics-api.buildkite.com/v1/uploads", + SuiteToken: "hunter2", + } + + if diff := cmp.Diff(want, cfg); diff != "" { + t.Errorf("ConfigFromEnv() (-want +got)\n%s", diff) + } +} + +func TestConfigFromEnv_missingToken(t *testing.T) { + _, err := ConfigFromEnv(env.Map{}) + if err == nil { + t.Fatal("expected error from ConfigFromEnv with no token") + } + + want, got := "BUILDKITE_ANALYTICS_TOKEN missing", err.Error() + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ConfigFromEnv() (-want +got):\n%s", diff) + } +} + +func TestConfigFromEnv_uploadURL(t *testing.T) { + cfg, _ := ConfigFromEnv(env.Map{ + "BUILDKITE_TEST_ENGINE_UPLOAD_URL": "http://localhost:1234/foo", + "BUILDKITE_ANALYTICS_TOKEN": "hello", + }) + + want := Config{ + UploadUrl: "http://localhost:1234/foo", + SuiteToken: "hello", + } + + if diff := cmp.Diff(want, cfg); diff != "" { + t.Errorf("ConfigFromEnv (-want +got)\n%s", diff) + } +} + +func TestBuildRunEnv(t *testing.T) { + runEnv, err := RunEnvFromEnv(env.Map{ + "BUILDKITE_BUILD_ID": "thebuild", + "BUILDKITE_BRANCH": "trunk", + "BUILDKITE_COMMIT": "cafe", + "BUILDKITE_JOB_ID": "thejob", + "BUILDKITE_MESSAGE": "hello world", + "BUILDKITE_BUILD_NUMBER": "42", + "BUILDKITE_BUILD_URL": "http://localhost/builds/42", + }) + if err != nil { + t.Errorf("buildRunEnv(): %v", err) + } + + want := RunEnvMap{ + "collector": "bktec", + "version": version.Version, + "CI": "buildkite", + "branch": "trunk", + "commit_sha": "cafe", + "job_id": "thejob", + "key": "thebuild", + "message": "hello world", + "number": "42", + "url": "http://localhost/builds/42", + } + + if diff := cmp.Diff(want, runEnv); diff != "" { + t.Errorf("buildRunEnv() (-want +got):\n%s", diff) + } +} + +func TestBuildRunEnv_generic(t *testing.T) { + runEnv, err := RunEnvFromEnv(env.Map{}) + if err != nil { + t.Errorf("buildRunEnv(): %v", err) + } + + want := RunEnvMap{ + "collector": "bktec", + "version": version.Version, + "CI": "generic", + "key": "00000000-0000-0000-0000-000000000000", // placeholder + } + + if diff := cmp.Diff(want, runEnv, cmpKeyValidUUID()); diff != "" { + t.Errorf("buildRunEnv() (-want +got):\n%s", diff) + } +} + +func TestUpload(t *testing.T) { + filename, xml := createTestXML(t) + defer os.Remove(filename) + + // receive request details from the HTTP handler + type requestInfo struct { + Method string + Path string + Authorization string + Data map[string]string + } + var gotRequestInfo requestInfo + + // fake API server + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, err := multipartToMap(r) + if err != nil { + t.Errorf("parsing request: %v", err) + } + + gotRequestInfo = requestInfo{ + Method: r.Method, + Path: r.URL.Path, + Authorization: r.Header.Get("Authorization"), + Data: data, + } + + w.WriteHeader(http.StatusAccepted) + io.WriteString(w, `{"id":"theuuid","url":"http://localhost/path/theuuid"}`) + })) + defer srv.Close() + + // Upload! + cfg := Config{ + UploadUrl: srv.URL + "/path", + SuiteToken: "hunter2", + } + runEnv := RunEnvMap{ + "CI": "buildkite", + "key": "thekey", + } + format := "junit" + ctx := context.Background() + responseData, err := Upload(ctx, cfg, runEnv, format, filename) + if err != nil { + t.Fatalf("upload failed: %v", err) + } + + // verify the HTTP request details + wantRequestInfo := requestInfo{ + Method: "POST", + Path: "/path", + Authorization: `Token token="hunter2"`, + Data: map[string]string{ + "data": xml, + "format": "junit", + "run_env[CI]": "buildkite", + "run_env[key]": "thekey", + }, + } + if diff := cmp.Diff(wantRequestInfo, gotRequestInfo); diff != "" { + t.Errorf("HTTP request (-want +got):\n%s", diff) + } + + wantResponseData := map[string]string{ + "id": "theuuid", + "url": "http://localhost/path/theuuid", + } + if diff := cmp.Diff(wantResponseData, responseData); diff != "" { + t.Errorf("HTTP response data (-want +got):\n%s", diff) + } +} + +// cmpKeyValidUUID is an Option for cmp.Diff that validates the values of `key` +// in two maps being compared are both valid UUIDs. Note that Comparer +// functions must be symmetric; they're run as fn(a,b) and fn(b,a). +func cmpKeyValidUUID() cmp.Option { + return cmp.FilterPath(func(path cmp.Path) bool { + return path.Last().String() == `["key"]` + }, cmp.Comparer(func(a, b string) bool { + return uuid.Validate(a) == nil && uuid.Validate(b) == nil + })) +} + +func createTestXML(t *testing.T) (string, string) { + data := `` + f, err := os.CreateTemp("", "test.xml") + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString(data) + if err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return f.Name(), data +} + +func getMultipartBoundary(contentType string) (string, error) { + mt, params, err := mime.ParseMediaType(contentType) + if err != nil { + return "", err + } + if want := "multipart/form-data"; mt != want { + return "", fmt.Errorf("Content-Type: wanted %s, got %s", want, mt) + } + boundary := params["boundary"] + if boundary == "" { + return "", fmt.Errorf("missing multipart boundary") + } + return boundary, nil +} + +func multipartToMap(r *http.Request) (map[string]string, error) { + boundary, err := getMultipartBoundary(r.Header.Get("Content-Type")) + if err != nil { + return nil, fmt.Errorf("getMultipartBoundary: %w", err) + } + mr := multipart.NewReader(r.Body, boundary) + parsed := map[string]string{} + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + return nil, fmt.Errorf("multipartToMap; NextPart: %w", err) + } + partData, err := io.ReadAll(p) + if err != nil { + return nil, fmt.Errorf("multipartToMap; ReadAll: %w", err) + } + parsed[p.FormName()] = string(partData) + } + return parsed, nil +} diff --git a/main.go b/main.go index 3472b9a3..858932cf 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ import ( "github.com/buildkite/test-engine-client/internal/env" "github.com/buildkite/test-engine-client/internal/plan" "github.com/buildkite/test-engine-client/internal/runner" + "github.com/buildkite/test-engine-client/internal/upload" "github.com/buildkite/test-engine-client/internal/version" "github.com/olekukonko/tablewriter" "golang.org/x/sys/unix" @@ -62,6 +63,14 @@ func main() { printStartUpMessage() + // TODO: proper subcommands + if flag.Arg(0) == "upload" { + if err := upload.UploadCLI(flag.CommandLine, env); err != nil { + logErrorAndExit(16, "upload: %v", err) + } + os.Exit(0) + } + // get config cfg, err := config.New(env) if err != nil {