blob: 7202d71b3171468c5f5549d0d422e6f9ec292015 [file] [log] [blame]
// Copyright 2018 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// This file defines the LKGS CLI tool which computes the last-known-good
// (-jiri)-snapshot given a set of parameters.
// TODO (nmulcahey): Add tests for this tool.
package main
import (
"bytes"
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"path"
"strings"
"google.golang.org/genproto/protobuf/field_mask"
"go.chromium.org/luci/auth"
"go.chromium.org/luci/auth/client/authcli"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/grpc/prpc"
"go.chromium.org/luci/hardcoded/chromeinfra"
"go.chromium.org/luci/logdog/client/coordinator"
"go.chromium.org/luci/logdog/common/renderer"
"go.chromium.org/luci/logdog/common/types"
)
// Implementing flag.Value
type builderFlag []string
func (builders *builderFlag) Set(builder string) error {
*builders = append(*builders, builder)
return nil
}
func (builders *builderFlag) String() string {
builderStrings := []string{}
for _, builder := range *builders {
builderStrings = append(builderStrings, string(builder))
}
return strings.Join(builderStrings, ",")
}
type pollErrorType int
const (
noBuilds = 0
noGreenBuilds = 1
buildBucketFailure = 2
)
type buildPollError struct {
builder string
gitilesRef string
kind pollErrorType
}
func (bpe buildPollError) Error() string {
switch kind := bpe.kind; kind {
case noBuilds:
return fmt.Sprintf("no builds found for builder %s", bpe.builder)
case noGreenBuilds:
refFilterSuffix := ""
if bpe.gitilesRef != "" {
refFilterSuffix = fmt.Sprintf(" on ref %s", bpe.gitilesRef)
}
return fmt.Sprintf("no green build found for builder %s%s", bpe.builder, refFilterSuffix)
case buildBucketFailure:
return fmt.Sprintf("buildbucker error %s", bpe.builder)
}
return fmt.Sprintf("Unknown Error: %s", bpe.builder)
}
var (
host string
builderIDs builderFlag
gitilesRef string
output string
// LUCI flags used to parse command-line authentication options.
authFlags authcli.Flags
buildMap = make(map[string][]*buildbucketpb.Build)
)
func init() {
flag.StringVar(&host, "host", "cr-buildbucket.appspot.com", "the buildbucket host to use (default is cr-buildbucket.appspot.com)")
flag.Var(&builderIDs, "builder-id", "[repeatable] name of the builders to use as a reference (e.g. fuchsia/ci/garnet-x64)")
flag.StringVar(&gitilesRef, "gitiles-ref", "", "optionally filter builds for matching gitiles ref (e.g. refs/heads/master). by default, no filter applied")
flag.StringVar(&output, "output-file", "", "name of the file to write snapshot to (default is stdout)")
authFlags = authcli.Flags{}
authFlags.Register(flag.CommandLine, chromeinfra.DefaultAuthOptions())
}
// praseBuilderID parses a builder ID of the form "fuchsia/ci/garnet-x64".
func parseBuilderID(builderID string) (*buildbucketpb.BuilderID, error) {
components := strings.SplitN(builderID, "/", 3)
if len(components) != 3 {
return nil, fmt.Errorf("failed to parse builder ID")
}
return &buildbucketpb.BuilderID{
Project: components[0],
Bucket: components[1],
Builder: components[2],
}, nil
}
// getLastKnownGoodBuild retrieves the last known good build for N builder IDs.
// TODO(nmulcahey): This should use BuildsClient.Batch to get builds from all builders.
func getLastKnownGoodBuild(ctx context.Context, buildsClient buildbucketpb.BuildsClient, builderIDs []*buildbucketpb.BuilderID, previousBuild *buildbucketpb.Build) (*buildbucketpb.Build, error) {
builderID := builderIDs[0]
if len(buildMap[builderID.String()]) == 0 {
res, err := buildsClient.SearchBuilds(ctx, &buildbucketpb.SearchBuildsRequest{
Predicate: &buildbucketpb.BuildPredicate{
Builder: builderID,
Status: buildbucketpb.Status_SUCCESS,
},
// Retrieve only the Infra & Input fields
// Infra.Logdog is how we retrieve the Jiri snapshot
// Input.GitilesCommit.Id is what we match builds on
// Input.GitilesCommit.Ref is what we optionally filter builds on
Fields: &field_mask.FieldMask{
Paths: []string{"builds.*.infra", "builds.*.input"},
},
})
if err != nil {
return nil, buildPollError{builder: builderID.String(), kind: buildBucketFailure}
}
if len(res.Builds) == 0 {
return nil, buildPollError{builder: builderID.String(), kind: noBuilds}
}
buildMap[builderID.String()] = res.Builds
}
// TODO(nmulcahey): Use iteration instead of recursion here.
for _, currentBuild := range buildMap[builderID.String()] {
// If the currentBuild has no GitilesCommit, someone manually triggered it and we need to skip it
if currentBuild.Input.GitilesCommit == nil {
continue
}
// If gitilesRef is specified, filter out builds which do not match refs
if gitilesRef != "" && currentBuild.Input.GitilesCommit.Ref != gitilesRef {
continue
}
// If previousBuild is nil, we are at the top-level and can recurse, otherwise only recurse
// if we found a matching green build
if previousBuild == nil || currentBuild.Input.GitilesCommit.Id == previousBuild.Input.GitilesCommit.Id {
// If processing the last builderID, return when a match is found
if len(builderIDs) > 1 {
nextBuild, err := getLastKnownGoodBuild(ctx, buildsClient, builderIDs[1:], currentBuild)
if err != nil {
// One of the builders has no builds; short-circuit to the top-level
// and exit with error
if obj, ok := err.(buildPollError); ok == true {
switch obj.kind {
case noGreenBuilds:
if previousBuild == nil {
continue
}
return nil, err
case noBuilds:
fallthrough
case buildBucketFailure:
fallthrough
default:
return nil, err
}
}
continue
}
return nextBuild, nil
}
return currentBuild, nil
}
}
return nil, buildPollError{builder: builderID.String(), gitilesRef: gitilesRef, kind: noGreenBuilds}
}
// getSnapshot retrieves the jiri snapshot from LogDog related to the build using that build's LogDog details.
func getSnapshot(ctx context.Context, client *http.Client, logdog *buildbucketpb.BuildInfra_LogDog) ([]byte, error) {
coordClient := coordinator.NewClient(&prpc.Client{
C: client,
Host: logdog.Hostname,
Options: prpc.DefaultOptions(),
})
logProject := types.ProjectName(logdog.Project)
// TODO(mknyszek): Consider making these snapshots easier to find. This should be resilient against
// step name changes, so long as the log itself has the same name, but it is kind of a hack.
logPath := path.Join(logdog.Prefix, "+", "**", "snapshot_contents", "*")
// Perform the query, capturing exactly one log stream and erroring otherwise.
var log *coordinator.LogStream
err := coordClient.Query(ctx, logProject, logPath, coordinator.QueryOptions{}, func(s *coordinator.LogStream) bool {
log = s
return false
})
switch {
case err != nil:
return nil, err
case log == nil:
return nil, fmt.Errorf(
"unable to find jiri snapshot in project %s at path %s",
logdog.Project, logPath)
}
// Read the source manifest from the log stream.
var buf bytes.Buffer
_, err = buf.ReadFrom(&renderer.Renderer{
Source: coordClient.Stream(logProject, log.Path).Fetcher(ctx, nil),
Raw: true,
})
return buf.Bytes(), err
}
func main() {
flag.Parse()
if builderIDs == nil {
flag.PrintDefaults()
return
}
ids := []*buildbucketpb.BuilderID{}
for _, builderID := range builderIDs {
id, err := parseBuilderID(builderID)
if err != nil {
log.Fatalf(err.Error())
}
ids = append(ids, id)
}
opts, err := authFlags.Options()
if err != nil {
log.Fatalf(err.Error())
}
ctx := context.Background()
authenticator := auth.NewAuthenticator(ctx, auth.OptionalLogin, opts)
client, err := authenticator.Client()
if err != nil {
log.Fatalf(err.Error())
}
buildsClient := buildbucketpb.NewBuildsPRPCClient(&prpc.Client{
C: client,
Host: host,
})
build, err := getLastKnownGoodBuild(ctx, buildsClient, ids, nil)
if err != nil {
log.Fatalf(err.Error())
}
snapshotBytes, err := getSnapshot(ctx, client, build.Infra.Logdog)
if err != nil {
log.Fatalf(err.Error())
}
var outputFile *os.File
if output == "" {
outputFile = os.Stdout
} else {
outputFile, err = os.Create(output)
if err != nil {
log.Fatalf(err.Error())
}
defer outputFile.Close()
}
_, err = outputFile.Write(snapshotBytes)
if err != nil {
log.Fatalf("writing output: %v", err)
}
}