go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/artifacts/storetestoutputs.go (about)

     1  // Copyright 2019 The Fuchsia Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"context"
     9  	"encoding/json"
    10  	"flag"
    11  	"fmt"
    12  	"io"
    13  	"log"
    14  	"os"
    15  	"sync"
    16  
    17  	"github.com/google/subcommands"
    18  	"go.chromium.org/luci/auth"
    19  	"go.chromium.org/luci/auth/client/authcli"
    20  	"go.chromium.org/luci/hardcoded/chromeinfra"
    21  	"go.fuchsia.dev/infra/artifacts"
    22  )
    23  
    24  const (
    25  	// The maximum number of concurrent uploads. We rate limit this because we don't know
    26  	// how many entries there are in the input manifest file, so we don't know how many
    27  	// go-routines will be kicked off during execution. At around 100 or so concurrent
    28  	// uploads, the Golang storage API starts returning 400 errors and files fail to flush
    29  	// (when writer.Close() is called). Tweak this number as needed.
    30  	maxConcurrentUploads = 100
    31  )
    32  
    33  // TestOutputsManifest describes how to upload test files. This is intentionally written
    34  // to match the schema of Fuchsia's existing summary.json "tests" field for backward
    35  // compatibility. We should migrate away from using a single output file for stdout and
    36  // stderr and prefer uploading separate files.
    37  type TestOutputsManifest = []TestOutputs
    38  
    39  type TestOutputs struct {
    40  	Name       string `json:"name"`
    41  	OutputFile string `json:"output_file"`
    42  }
    43  
    44  // StoreTestOutputsCommand performs a batch upload of test outputs to Cloud Storage.
    45  type StoreTestOutputsCommand struct {
    46  	authFlags authcli.Flags
    47  	bucket    string
    48  	build     string
    49  	testEnv   string
    50  	workers   sync.WaitGroup
    51  }
    52  
    53  func (*StoreTestOutputsCommand) Name() string {
    54  	return "storetestoutputs"
    55  }
    56  
    57  func (*StoreTestOutputsCommand) Usage() string {
    58  	return fmt.Sprintf(`storetestoutputs [flags] outputs.json
    59  
    60  The input manifest is a JSON list of objects with the following scheme:
    61  
    62  	{
    63  	  "name": "The name of the test",
    64  	  "output_file": "/path/to/test/output/file"
    65  	}
    66  
    67  output_file is written to Cloud Storage as just %q within the hierarchy documented in
    68  //tools/artifacts/doc.go.`, artifacts.DefaultTestOutputName)
    69  }
    70  
    71  func (*StoreTestOutputsCommand) Synopsis() string {
    72  	return fmt.Sprintf("stores test output files in Cloud Storage")
    73  }
    74  
    75  func (cmd *StoreTestOutputsCommand) SetFlags(f *flag.FlagSet) {
    76  	cmd.authFlags.Register(flag.CommandLine, chromeinfra.DefaultAuthOptions())
    77  	f.StringVar(&cmd.bucket, "bucket", "", "The Cloud Storage bucket to write to")
    78  	f.StringVar(&cmd.build, "build", "", "The BuildBucket build ID")
    79  	f.StringVar(&cmd.testEnv, "testenv", "", "A canonical name for the test environment")
    80  }
    81  
    82  func (cmd *StoreTestOutputsCommand) Execute(ctx context.Context, f *flag.FlagSet, _ ...any) subcommands.ExitStatus {
    83  	opts, err := cmd.authFlags.Options()
    84  	if err != nil {
    85  		log.Println(err)
    86  		return subcommands.ExitFailure
    87  	}
    88  	if err := cmd.validateFlags(f); err != nil {
    89  		log.Println(err)
    90  		return subcommands.ExitFailure
    91  	}
    92  	manifest, err := cmd.parseManifestFile(f.Arg(0))
    93  	if err != nil {
    94  		log.Println(err)
    95  		return subcommands.ExitFailure
    96  	}
    97  	if !cmd.execute(ctx, manifest, opts) {
    98  		return subcommands.ExitFailure
    99  	}
   100  	return subcommands.ExitSuccess
   101  }
   102  
   103  func (cmd *StoreTestOutputsCommand) parseManifestFile(path string) (TestOutputsManifest, error) {
   104  	bytes, err := os.ReadFile(path)
   105  	if err != nil {
   106  		return nil, fmt.Errorf("failed to read %q: %w", path, err)
   107  	}
   108  	var manifest TestOutputsManifest
   109  	if err := json.Unmarshal(bytes, &manifest); err != nil {
   110  		return nil, fmt.Errorf("fail to unmarshal manifest: %w", err)
   111  	}
   112  	return manifest, nil
   113  }
   114  
   115  func (cmd *StoreTestOutputsCommand) validateFlags(f *flag.FlagSet) error {
   116  	if f.NArg() != 1 {
   117  		return fmt.Errorf("expect exactly 1 positional argument")
   118  	}
   119  	if cmd.bucket == "" {
   120  		return fmt.Errorf("missing -bucket")
   121  	}
   122  	if cmd.build == "" {
   123  		return fmt.Errorf("missing -build")
   124  	}
   125  	if cmd.testEnv == "" {
   126  		return fmt.Errorf("missing -testenv")
   127  	}
   128  	return nil
   129  }
   130  
   131  // execute spawns a worker pool to perform the upload. The pool is limited in size by
   132  // maxConcurrentUploads to avoid load failures in the storage API.
   133  func (cmd *StoreTestOutputsCommand) execute(ctx context.Context, manifest TestOutputsManifest, opts auth.Options) bool {
   134  	const workerCount = maxConcurrentUploads
   135  	success := true
   136  	outputs := make(chan TestOutputs)
   137  	errs := make(chan error, workerCount)
   138  	for range workerCount {
   139  		cmd.workers.Add(1)
   140  		go cmd.worker(ctx, &cmd.workers, outputs, errs, opts)
   141  	}
   142  	for _, entry := range manifest {
   143  		outputs <- entry
   144  	}
   145  	close(outputs)
   146  	go func() {
   147  		for err := range errs {
   148  			success = false
   149  			log.Println(err)
   150  		}
   151  	}()
   152  	cmd.workers.Wait()
   153  	close(errs)
   154  	return success
   155  }
   156  
   157  func (cmd *StoreTestOutputsCommand) worker(ctx context.Context, wg *sync.WaitGroup, outputs <-chan TestOutputs, errs chan<- error, opts auth.Options) {
   158  	defer wg.Done()
   159  	cli, err := artifacts.NewClient(ctx, opts)
   160  	if err != nil {
   161  		errs <- err
   162  		return
   163  	}
   164  	dir := cli.GetBuildDir(cmd.bucket, cmd.build).(*artifacts.BuildDirectory)
   165  	for output := range outputs {
   166  		if err := cmd.upload(context.Background(), output, dir); err != nil {
   167  			errs <- err
   168  		}
   169  	}
   170  }
   171  
   172  func (cmd *StoreTestOutputsCommand) upload(ctx context.Context, outputs TestOutputs, dir *artifacts.BuildDirectory) error {
   173  	fd, err := os.Open(outputs.OutputFile)
   174  	if err != nil {
   175  		return fmt.Errorf("failed to read %q: %w", outputs.OutputFile, err)
   176  	}
   177  	object := dir.NewTestOutputObject(ctx, outputs.Name, cmd.testEnv)
   178  	writer := object.NewWriter(ctx)
   179  	if _, err := io.Copy(writer, fd); err != nil {
   180  		return fmt.Errorf("failed to write %q: %w", object.ObjectName(), err)
   181  	}
   182  	if err := writer.Close(); err != nil {
   183  		return fmt.Errorf("failed to flush bufferfor %q: %w", object.ObjectName(), err)
   184  	}
   185  	return nil
   186  }