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

     1  // Copyright 2021 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  	"context"
    19  	"fmt"
    20  	"os"
    21  	"strings"
    22  
    23  	"github.com/maruel/subcommands"
    24  
    25  	"google.golang.org/protobuf/encoding/prototext"
    26  
    27  	"go.chromium.org/luci/auth"
    28  	"go.chromium.org/luci/auth/client/authcli"
    29  	"go.chromium.org/luci/common/api/gerrit"
    30  	"go.chromium.org/luci/common/cli"
    31  	"go.chromium.org/luci/common/data/text"
    32  	"go.chromium.org/luci/common/errors"
    33  	gerritpb "go.chromium.org/luci/common/proto/gerrit"
    34  	"go.chromium.org/luci/common/sync/parallel"
    35  	lucivalidation "go.chromium.org/luci/config/validation"
    36  
    37  	cfgpb "go.chromium.org/luci/cv/api/config/v2"
    38  	"go.chromium.org/luci/cv/internal/configs/prjcfg"
    39  	"go.chromium.org/luci/cv/internal/configs/validation"
    40  	"go.chromium.org/luci/cv/internal/gerrit/cfgmatcher"
    41  )
    42  
    43  func cmdMatchConfig(p Params) *subcommands.Command {
    44  	return &subcommands.Command{
    45  		UsageLine: "match-config [flags] CFG_PATH CL1 [CL2 ...] ",
    46  		ShortDesc: "Match given CL(s) against given config.",
    47  		LongDesc: text.Doc(`
    48  			With a given configuration file, validate it, and determine the configuration
    49  			that would apply to the given CL(s).
    50  
    51  			CFG_PATH must be the path to a generated "commit-queue.cfg" file.
    52  			CL1, CL2, etc. must be given as URLs to Gerrit CLs e.g.:
    53  			  "https://chromium-review.googlesource.com/c/infra/luci/luci-go/+/3198992"
    54  			  "https://crrev.com/c/3198992"
    55  		`),
    56  		CommandRun: func() subcommands.CommandRun {
    57  			r := &matchConfigRun{}
    58  			r.authFlags.Register(&r.Flags, p.Auth)
    59  			return r
    60  		},
    61  	}
    62  }
    63  
    64  type matchConfigRun struct {
    65  	subcommands.CommandRunBase
    66  	authFlags authcli.Flags
    67  }
    68  
    69  func (r *matchConfigRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
    70  	ctx := cli.GetContext(a, r, env)
    71  
    72  	if err := r.validateArgs(ctx, args); err != nil {
    73  		return r.done(badArgsTag.Apply(err))
    74  	}
    75  
    76  	config, err := loadAndValidateConfig(ctx, args[0])
    77  	if err != nil {
    78  		return r.done(err)
    79  	}
    80  
    81  	// cfgmatcher works with CV's storage-layer prjcfg.ConfigGroups,
    82  	// which includes more than just the group name.
    83  	// So, provide cfgmatcher config groups with empty hash as it doesn't matter
    84  	// for CV CLI use case.
    85  	prjCfgGroups := make([]*prjcfg.ConfigGroup, len(config.ConfigGroups))
    86  	for i, cg := range config.ConfigGroups {
    87  		prjCfgGroups[i] = &prjcfg.ConfigGroup{Content: cg, ID: prjcfg.MakeConfigGroupID("", cg.GetName())}
    88  	}
    89  
    90  	clURLs := args[1:]
    91  	results := make([]matchResult, len(clURLs))
    92  	err = parallel.FanOutIn(func(work chan<- func() error) {
    93  		for i, clURL := range clURLs {
    94  			i, clURL := i, clURL
    95  			matcher := cfgmatcher.LoadMatcherFromConfigGroups(ctx, prjCfgGroups, nil)
    96  			work <- func() error {
    97  				results[i] = r.match(ctx, clURL, matcher)
    98  				return nil
    99  			}
   100  		}
   101  	})
   102  	if err != nil {
   103  		panic("impossible: workpool returned error")
   104  	}
   105  	errs := errors.MultiError(nil)
   106  	for i, mr := range results {
   107  		fmt.Printf("\n%s:\n", clURLs[i])
   108  		fmt.Printf("  Location: Host: %s, Repo: %s, Ref: %s\n", mr.Host, mr.Repo, mr.Ref)
   109  		if len(mr.Names) != 0 {
   110  			fmt.Printf("  Matched: %s\n", strings.Join(mr.Names, ", "))
   111  		}
   112  		if mr.Error != nil {
   113  			fmt.Printf("  Error: %s\n", mr.Error)
   114  			errs.MaybeAdd(mr.Error)
   115  		}
   116  	}
   117  	return r.done(errs.AsError())
   118  }
   119  
   120  type matchResult struct {
   121  	Host, Repo, Ref string
   122  	Names           []string
   123  	Error           error
   124  }
   125  
   126  func (r *matchConfigRun) match(ctx context.Context, url string, matcher *cfgmatcher.Matcher) matchResult {
   127  	ret := matchResult{}
   128  
   129  	host, change, err := gerrit.FuzzyParseURL(url)
   130  	if err != nil {
   131  		ret.Error = err
   132  		return ret
   133  	}
   134  
   135  	// We use a new client for each CL because their hosts may be different.
   136  	client, err := r.newGerritClient(ctx, host)
   137  	if err != nil {
   138  		ret.Error = err
   139  		return ret
   140  	}
   141  
   142  	info, err := client.GetChange(ctx, &gerritpb.GetChangeRequest{Number: change})
   143  	if err != nil {
   144  		ret.Error = err
   145  		return ret
   146  	}
   147  
   148  	ret.Host, ret.Repo, ret.Ref = host, info.GetProject(), info.GetRef()
   149  
   150  	ids := matcher.Match(ret.Host, ret.Repo, ret.Ref)
   151  	for _, id := range ids {
   152  		ret.Names = append(ret.Names, id.Name())
   153  	}
   154  
   155  	if len(ret.Names) == 0 {
   156  		ret.Error = errors.Reason("the CL did not match any config groups").Err()
   157  	}
   158  	if len(ret.Names) > 1 {
   159  		ret.Error = errors.Reason("the CL matched multiple config groups").Err()
   160  	}
   161  	return ret
   162  }
   163  
   164  func (r *matchConfigRun) validateArgs(ctx context.Context, args []string) error {
   165  	if len(args) < 2 {
   166  		return errors.Reason("At least 2 arguments are required").Err()
   167  	}
   168  	for i, arg := range args {
   169  		if i == 0 {
   170  			// Ensure cfg file exists.
   171  			_, err := os.Stat(arg)
   172  			if err != nil {
   173  				return err
   174  			}
   175  		} else {
   176  			// Ensure CL URLs are valid.
   177  			_, _, err := gerrit.FuzzyParseURL(arg)
   178  			if err != nil {
   179  				return err
   180  			}
   181  		}
   182  	}
   183  	return nil
   184  }
   185  
   186  func loadAndValidateConfig(ctx context.Context, cfgPath string) (*cfgpb.Config, error) {
   187  	in, err := os.ReadFile(cfgPath)
   188  	if err != nil {
   189  		return nil, err
   190  	}
   191  	ret := &cfgpb.Config{}
   192  	err = prototext.Unmarshal(in, ret)
   193  	if err != nil {
   194  		return nil, err
   195  	}
   196  	vctx := &lucivalidation.Context{Context: ctx}
   197  	if err := validation.ValidateProjectConfig(vctx, ret); err != nil {
   198  		return nil, err
   199  	}
   200  	return ret, vctx.Finalize()
   201  }
   202  
   203  func (r *matchConfigRun) newGerritClient(ctx context.Context, host string) (gerritpb.GerritClient, error) {
   204  	authOpts, err := r.authFlags.Options()
   205  	if err != nil {
   206  		return nil, err
   207  	}
   208  	c, err := auth.NewAuthenticator(ctx, auth.SilentLogin, authOpts).Client()
   209  	switch {
   210  	case err == auth.ErrLoginRequired:
   211  		return nil, errors.New("Login required: run `luci-cv auth-login`")
   212  	case err != nil:
   213  		return nil, err
   214  	}
   215  	return gerrit.NewRESTClient(c, host, true)
   216  }
   217  
   218  func (r *matchConfigRun) done(err error) int {
   219  	if err == nil {
   220  		return 0
   221  	}
   222  	fmt.Fprintln(os.Stderr, err)
   223  	_, badArgs := errors.TagValueIn(badArgsTag.Key, err)
   224  	if badArgs {
   225  		return 2
   226  	}
   227  	return 1
   228  }