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 }