go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/cli/cmd_query.go (about)

     1  // Copyright 2019 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package cli
    16  
    17  import (
    18  	"bufio"
    19  	"bytes"
    20  	"context"
    21  	"encoding/json"
    22  	"fmt"
    23  	"io"
    24  	"os"
    25  	"strings"
    26  	"time"
    27  
    28  	"github.com/golang/protobuf/jsonpb"
    29  	"github.com/golang/protobuf/proto"
    30  	"github.com/maruel/subcommands"
    31  	"golang.org/x/sync/errgroup"
    32  	"google.golang.org/protobuf/encoding/protojson"
    33  	"google.golang.org/protobuf/types/known/fieldmaskpb"
    34  
    35  	"go.chromium.org/luci/auth"
    36  	"go.chromium.org/luci/common/cli"
    37  	"go.chromium.org/luci/common/data/text"
    38  	"go.chromium.org/luci/common/data/text/indented"
    39  	"go.chromium.org/luci/common/errors"
    40  
    41  	"go.chromium.org/luci/resultdb/pbutil"
    42  	pb "go.chromium.org/luci/resultdb/proto/v1"
    43  )
    44  
    45  func cmdQuery(p Params) *subcommands.Command {
    46  	return &subcommands.Command{
    47  		UsageLine: `query [flags] [INVOCATION_ID]...`,
    48  		ShortDesc: "query results",
    49  		LongDesc: text.Doc(`
    50  			Query results.
    51  
    52  			Most users will be interested only in results of test variants that had
    53  			unexpected results. This can be achieved by passing -u flag.
    54  			This significantly reduces output size and latency.
    55  
    56  			If no invocation ids are specified on the command line, read them from
    57  			stdin separated by newline. Example:
    58  			  bb chromium/ci/linux-rel -status failure -inv -10 | rdb query
    59  		`),
    60  		CommandRun: func() subcommands.CommandRun {
    61  			r := &queryRun{}
    62  			r.RegisterGlobalFlags(p)
    63  			r.RegisterJSONFlag(text.Doc(`
    64  				Print results in JSON format separated by newline.
    65  				One result takes exactly one line. Result object properties are invocationId
    66  				and one of
    67  					testResult: luci.resultdb.v1.TestResult message.
    68  					testExoneration: luci.resultdb.v1.TestExoneration message.
    69  					invocation: luci.resultdb.v1.Invocation message.
    70  			`))
    71  
    72  			r.Flags.IntVar(&r.limit, "n", 0, text.Doc(`
    73  				Print up to n results. If 0, then unlimited.
    74  				Invocations do not count as results.
    75  			`))
    76  
    77  			r.Flags.BoolVar(&r.unexpected, "u", false, text.Doc(`
    78  				Print only test results of test variants that have unexpected results.
    79  				For example, if a test variant expected PASS and had results FAIL, FAIL,
    80  				PASS, then print all of them.
    81  				This signficantly reduces output size and latency.
    82  			`))
    83  
    84  			r.Flags.StringVar(&r.testID, "test", "", text.Doc(`
    85  				A regular expression for test id. Implicitly wrapped with ^ and $.
    86  
    87  				Example: ninja://chrome/test:browser_tests/.+
    88  			`))
    89  
    90  			r.Flags.BoolVar(&r.merge, "merge", false, text.Doc(`
    91  				Merge results of the invocations, as if they were included into one
    92  				invocation.
    93  				Useful when the invocations are a part of one computation, e.g. shards
    94  				of a test.
    95  			`))
    96  
    97  			r.Flags.StringVar(&r.trFields, "tr-fields", "", text.Doc(`
    98  				Test result fields to include in the response. Fields should be passed
    99  				as a comma separated string to match the JSON encoding schema of FieldMask.
   100  				Test result names will always be included even if "name" is not a part
   101  				of the fields.
   102  			`))
   103  			return r
   104  		},
   105  	}
   106  }
   107  
   108  type queryRun struct {
   109  	baseCommandRun
   110  
   111  	limit      int
   112  	unexpected bool
   113  	testID     string
   114  	merge      bool
   115  	trFields   string
   116  	invIDs     []string
   117  
   118  	// TODO(crbug.com/1021849): add flag -artifact-dir
   119  	// TODO(crbug.com/1021849): add flag -artifact-name
   120  }
   121  
   122  func (r *queryRun) parseArgs(args []string) error {
   123  	r.invIDs = args
   124  	if len(r.invIDs) == 0 {
   125  		var err error
   126  		if r.invIDs, err = readStdin(); err != nil {
   127  			return err
   128  		}
   129  	}
   130  
   131  	for _, id := range r.invIDs {
   132  		if err := pbutil.ValidateInvocationID(id); err != nil {
   133  			return errors.Annotate(err, "invocation id %q", id).Err()
   134  		}
   135  	}
   136  
   137  	if r.limit < 0 {
   138  		return errors.Reason("-n must be non-negative").Err()
   139  	}
   140  
   141  	// TODO(crbug.com/1021849): improve validation.
   142  	return nil
   143  }
   144  
   145  func (r *queryRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
   146  	ctx := cli.GetContext(a, r, env)
   147  
   148  	if err := r.parseArgs(args); err != nil {
   149  		return r.done(err)
   150  	}
   151  
   152  	if err := r.initClients(ctx, auth.SilentLogin); err != nil {
   153  		return r.done(err)
   154  	}
   155  
   156  	return r.done(r.queryAndPrint(ctx, r.invIDs))
   157  }
   158  
   159  // readStdin reads all lines from os.Stdin.
   160  func readStdin() ([]string, error) {
   161  	// This context is used only to cancel the goroutine below.
   162  	ctx, cancel := context.WithCancel(context.Background())
   163  	defer cancel()
   164  	go func() {
   165  		select {
   166  		case <-time.After(time.Second):
   167  			fmt.Fprintln(os.Stderr, "expecting invocation ids on the command line or stdin...")
   168  		case <-ctx.Done():
   169  		}
   170  	}()
   171  
   172  	var ret []string
   173  	stdin := bufio.NewReader(os.Stdin)
   174  	for {
   175  		line, err := stdin.ReadString('\n')
   176  		if err == io.EOF {
   177  			return ret, nil
   178  		}
   179  		if err != nil {
   180  			return nil, err
   181  		}
   182  		ret = append(ret, strings.TrimSuffix(line, "\n"))
   183  		cancel() // do not print the warning since we got something.
   184  	}
   185  }
   186  
   187  type resultItem struct {
   188  	invocationID string
   189  	result       proto.Message
   190  }
   191  
   192  // queryAndPrint queries results and prints them.
   193  func (r *queryRun) queryAndPrint(ctx context.Context, invIDs []string) error {
   194  	eg, ctx := errgroup.WithContext(ctx)
   195  	resultC := make(chan resultItem)
   196  
   197  	for _, id := range invIDs {
   198  		id := id
   199  		eg.Go(func() error {
   200  			return r.fetchInvocation(ctx, id, resultC)
   201  		})
   202  	}
   203  
   204  	trMask := &fieldmaskpb.FieldMask{}
   205  	if r.trFields != "" {
   206  		if err := protojson.Unmarshal([]byte(fmt.Sprintf(`"%s"`, r.trFields)), trMask); err != nil {
   207  			return errors.Annotate(err, "tr-fields").Err()
   208  		}
   209  	}
   210  
   211  	// Fetch items into resultC.
   212  	if r.merge {
   213  		eg.Go(func() error {
   214  			return r.fetchItems(ctx, invIDs, trMask, resultItem{}, resultC)
   215  		})
   216  	} else {
   217  		for _, id := range invIDs {
   218  			id := id
   219  			tmpl := resultItem{invocationID: id}
   220  			eg.Go(func() error {
   221  				return r.fetchItems(ctx, []string{id}, trMask, tmpl, resultC)
   222  			})
   223  		}
   224  	}
   225  
   226  	// Wait for fetchers to finish and close resultC.
   227  	errC := make(chan error)
   228  	go func() {
   229  		err := eg.Wait()
   230  		close(resultC)
   231  		errC <- err
   232  	}()
   233  
   234  	r.printProto(resultC, r.json)
   235  	return <-errC
   236  }
   237  
   238  // fetchInvocation fetches an invocation.
   239  func (r *queryRun) fetchInvocation(ctx context.Context, invID string, dest chan<- resultItem) error {
   240  	res, err := r.resultdb.GetInvocation(ctx, &pb.GetInvocationRequest{Name: pbutil.InvocationName(invID)})
   241  	if err != nil {
   242  		return err
   243  	}
   244  	dest <- resultItem{invocationID: invID, result: res}
   245  	return nil
   246  }
   247  
   248  // fetchItems fetches test results and exonerations from the specified invocations.
   249  func (r *queryRun) fetchItems(ctx context.Context, invIDs []string, trMask *fieldmaskpb.FieldMask, resultItemTemplate resultItem, dest chan<- resultItem) error {
   250  	invNames := make([]string, len(invIDs))
   251  	for i, id := range invIDs {
   252  		invNames[i] = pbutil.InvocationName(id)
   253  	}
   254  
   255  	// Prepare a test result request.
   256  	trReq := &pb.QueryTestResultsRequest{
   257  		Invocations: invNames,
   258  		Predicate:   &pb.TestResultPredicate{TestIdRegexp: r.testID},
   259  		PageSize:    int32(r.limit),
   260  		ReadMask:    trMask,
   261  	}
   262  	if r.unexpected {
   263  		trReq.Predicate.Expectancy = pb.TestResultPredicate_VARIANTS_WITH_UNEXPECTED_RESULTS
   264  	}
   265  
   266  	// Prepare a test exoneration request.
   267  	teReq := &pb.QueryTestExonerationsRequest{
   268  		Invocations: invNames,
   269  		Predicate: &pb.TestExonerationPredicate{
   270  			TestIdRegexp: r.testID,
   271  		},
   272  		PageSize: int32(r.limit),
   273  	}
   274  
   275  	// Query for results.
   276  	msgC := make(chan proto.Message)
   277  	errC := make(chan error, 1)
   278  	queryCtx, cancelQuery := context.WithCancel(ctx)
   279  	defer cancelQuery()
   280  	go func() {
   281  		defer close(msgC)
   282  		errC <- pbutil.Query(queryCtx, msgC, r.resultdb, trReq, teReq)
   283  	}()
   284  
   285  	// Send findings to the destination channel.
   286  	count := 0
   287  	reachedLimit := false
   288  	for m := range msgC {
   289  		if r.limit > 0 && count > r.limit {
   290  			reachedLimit = true
   291  			cancelQuery()
   292  			break
   293  		}
   294  		count++
   295  
   296  		item := resultItemTemplate
   297  		item.result = m
   298  		select {
   299  		case dest <- item:
   300  		case <-ctx.Done():
   301  			return ctx.Err()
   302  		}
   303  	}
   304  
   305  	// Return the query error.
   306  	err := <-errC
   307  	if reachedLimit && err == context.Canceled {
   308  		err = nil
   309  	}
   310  	return err
   311  }
   312  
   313  // printProto prints results in JSON or TextProto format to stdout.
   314  // Each result takes exactly one line and is followed by newline.
   315  //
   316  // The printed JSON supports streaming, and is easy to parse by languages (Python)
   317  // that cannot parse an arbitrary sequence of JSON values.
   318  func (r *queryRun) printProto(resultC <-chan resultItem, printJSON bool) {
   319  	enc := json.NewEncoder(os.Stdout)
   320  	ind := &indented.Writer{
   321  		Writer:    os.Stdout,
   322  		UseSpaces: true,
   323  	}
   324  	for res := range resultC {
   325  		var key string
   326  		switch res.result.(type) {
   327  		case *pb.TestResult:
   328  			key = "testResult"
   329  		case *pb.TestExoneration:
   330  			key = "testExoneration"
   331  		case *pb.Invocation:
   332  			key = "invocation"
   333  		default:
   334  			panic(fmt.Sprintf("unexpected result type %T", res.result))
   335  		}
   336  
   337  		if printJSON {
   338  			obj := map[string]any{
   339  				key: json.RawMessage(msgToJSON(res.result)),
   340  			}
   341  			if !r.merge {
   342  				obj["invocationId"] = res.invocationID
   343  			}
   344  			enc.Encode(obj) // prints \n in the end
   345  		} else {
   346  			fmt.Fprintf(ind, "%s:\n", key)
   347  			ind.Level++
   348  			proto.MarshalText(ind, res.result)
   349  			ind.Level--
   350  			fmt.Fprintln(ind)
   351  		}
   352  	}
   353  }
   354  
   355  func msgToJSON(msg proto.Message) []byte {
   356  	buf := &bytes.Buffer{}
   357  	m := jsonpb.Marshaler{}
   358  	if err := m.Marshal(buf, msg); err != nil {
   359  		panic(fmt.Sprintf("failed to marshal protobuf message %q in memory", msg))
   360  	}
   361  	return buf.Bytes()
   362  }