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

     1  // Copyright 2018 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 base contains code shared by other CLI subpackages.
    16  package base
    17  
    18  import (
    19  	"bytes"
    20  	"context"
    21  	"encoding/json"
    22  	"flag"
    23  	"fmt"
    24  	"net/http"
    25  	"os"
    26  	"strings"
    27  
    28  	"github.com/maruel/subcommands"
    29  	"google.golang.org/grpc"
    30  	"google.golang.org/grpc/credentials"
    31  
    32  	"go.chromium.org/luci/auth"
    33  	"go.chromium.org/luci/auth/client/authcli"
    34  	"go.chromium.org/luci/common/errors"
    35  	"go.chromium.org/luci/common/flag/stringmapflag"
    36  	"go.chromium.org/luci/common/logging"
    37  
    38  	"go.chromium.org/luci/lucicfg"
    39  )
    40  
    41  // CommandLineError is used to tag errors related to command line arguments.
    42  //
    43  // Subcommand.Done(..., err) will print the usage string if it finds such error.
    44  type CommandLineError struct {
    45  	error
    46  }
    47  
    48  // NewCLIError returns new CommandLineError.
    49  func NewCLIError(msg string, args ...any) error {
    50  	return CommandLineError{fmt.Errorf(msg, args...)}
    51  }
    52  
    53  // MissingFlagError is CommandLineError about a missing flag.
    54  func MissingFlagError(flag string) error {
    55  	return NewCLIError("%s is required", flag)
    56  }
    57  
    58  // Parameters can be used to customize CLI defaults.
    59  type Parameters struct {
    60  	AuthOptions       auth.Options // mostly for client ID and client secret
    61  	ConfigServiceHost string       // e.g. "config.luci.app"
    62  }
    63  
    64  // Subcommand is a base of all subcommands.
    65  //
    66  // It defines some common flags, such as logging and JSON output parameters,
    67  // and some common methods to report errors and dump JSON output.
    68  //
    69  // It's Init() method should be called from within CommandRun to register
    70  // base flags.
    71  type Subcommand struct {
    72  	subcommands.CommandRunBase
    73  
    74  	Meta lucicfg.Meta        // meta config settable via CLI flags
    75  	Vars stringmapflag.Value // all `-var k=v` flags
    76  
    77  	params     *Parameters    // whatever was passed to Init
    78  	logConfig  logging.Config // for -log-level, used by ModifyContext
    79  	authFlags  authcli.Flags  // for -service-account-json, used by ConfigService
    80  	jsonOutput string         // for -json-output, used by Done
    81  }
    82  
    83  // ModifyContext implements cli.ContextModificator.
    84  func (c *Subcommand) ModifyContext(ctx context.Context) context.Context {
    85  	return c.logConfig.Set(ctx)
    86  }
    87  
    88  // Init registers common flags.
    89  func (c *Subcommand) Init(params Parameters) {
    90  	c.params = &params
    91  	c.Meta = c.DefaultMeta()
    92  
    93  	c.logConfig.Level = logging.Info
    94  	c.logConfig.AddFlags(&c.Flags)
    95  
    96  	c.authFlags.Register(&c.Flags, params.AuthOptions)
    97  	c.Flags.StringVar(&c.jsonOutput, "json-output", "", "Path to write operation results to.")
    98  }
    99  
   100  // DefaultMeta returns Meta values to use by default if not overridden via flags
   101  // or via lucicfg.config(...).
   102  func (c *Subcommand) DefaultMeta() lucicfg.Meta {
   103  	if c.params == nil {
   104  		panic("call Init first")
   105  	}
   106  	return lucicfg.Meta{
   107  		ConfigServiceHost: c.params.ConfigServiceHost,
   108  		ConfigDir:         "generated",
   109  		// Do not enforce formatting and linting by default for now.
   110  		LintChecks: []string{"none"},
   111  	}
   112  }
   113  
   114  // AddGeneratorFlags registers c.Meta and c.Vars in the FlagSet.
   115  //
   116  // Used by subcommands that end up executing Starlark.
   117  func (c *Subcommand) AddGeneratorFlags() {
   118  	if c.params == nil {
   119  		panic("call Init first")
   120  	}
   121  	c.Meta.AddFlags(&c.Flags)
   122  	c.Flags.Var(&c.Vars, "var",
   123  		"A `k=v` pair setting a value of some lucicfg.var(expose_as=...) variable, can be used multiple times (to set multiple vars).")
   124  }
   125  
   126  // CheckArgs checks command line args.
   127  //
   128  // It ensures all required positional and flag-like parameters are set. Setting
   129  // maxPosCount to -1 indicates there is unbounded number of positional arguments
   130  // allowed.
   131  //
   132  // Returns true if they are, or false (and prints to stderr) if not.
   133  func (c *Subcommand) CheckArgs(args []string, minPosCount, maxPosCount int) bool {
   134  	// Check number of expected positional arguments.
   135  	if len(args) < minPosCount || (maxPosCount >= 0 && len(args) > maxPosCount) {
   136  		var err error
   137  		switch {
   138  		case maxPosCount == 0:
   139  			err = NewCLIError("unexpected arguments %v", args)
   140  		case minPosCount == maxPosCount:
   141  			err = NewCLIError("expecting %d positional argument, got %d instead", minPosCount, len(args))
   142  		case maxPosCount >= 0:
   143  			err = NewCLIError(
   144  				"expecting from %d to %d positional arguments, got %d instead",
   145  				minPosCount, maxPosCount, len(args))
   146  		default:
   147  			err = NewCLIError(
   148  				"expecting at least %d positional arguments, got %d instead",
   149  				minPosCount, len(args))
   150  		}
   151  		c.printError(err)
   152  		return false
   153  	}
   154  
   155  	// Check required unset flags. A flag is considered required if its default
   156  	// value has form '<...>'.
   157  	unset := []*flag.Flag{}
   158  	c.Flags.VisitAll(func(f *flag.Flag) {
   159  		d := f.DefValue
   160  		if strings.HasPrefix(d, "<") && strings.HasSuffix(d, ">") && f.Value.String() == d {
   161  			unset = append(unset, f)
   162  		}
   163  	})
   164  	if len(unset) != 0 {
   165  		missing := make([]string, len(unset))
   166  		for i, f := range unset {
   167  			missing[i] = f.Name
   168  		}
   169  		c.printError(NewCLIError("missing required flags: %v", missing))
   170  		return false
   171  	}
   172  
   173  	return true
   174  }
   175  
   176  // LegacyConfigServiceClient returns an authenticated client to call legacy
   177  // LUCI Config service.
   178  func (c *Subcommand) LegacyConfigServiceClient(ctx context.Context) (*http.Client, error) {
   179  	authOpts, err := c.authFlags.Options()
   180  	if err != nil {
   181  		return nil, err
   182  	}
   183  	return auth.NewAuthenticator(ctx, auth.SilentLogin, authOpts).Client()
   184  }
   185  
   186  // luciConfigRetryPolicy is the default grpc retry policy for LUCI Config client
   187  const luciConfigRetryPolicy = `{
   188  	"methodConfig": [{
   189  		"name": [{ "service": "config.service.v2.Configs" }],
   190  		"timeout": "120s",
   191  		"retryPolicy": {
   192  		  "maxAttempts": 3,
   193  		  "initialBackoff": "0.1s",
   194  		  "maxBackoff": "1s",
   195  		  "backoffMultiplier": 2,
   196  		  "retryableStatusCodes": ["UNAVAILABLE", "INTERNAL", "UNKNOWN"]
   197  		}
   198  	}]
   199  }`
   200  
   201  // MakeConfigServiceConn returns an authenticated grpc connection to call
   202  // call LUCI Config service.
   203  func (c *Subcommand) MakeConfigServiceConn(ctx context.Context, host string) (*grpc.ClientConn, error) {
   204  	authOpts, err := c.authFlags.Options()
   205  	if err != nil {
   206  		return nil, err
   207  	}
   208  	authOpts.UseIDTokens = true
   209  	authOpts.Audience = "https://" + host
   210  
   211  	creds, err := auth.NewAuthenticator(ctx, auth.SilentLogin, authOpts).PerRPCCredentials()
   212  	if err != nil {
   213  		return nil, errors.Annotate(err, "failed to get credentials to access %s", host).Err()
   214  	}
   215  	conn, err := grpc.DialContext(ctx, host+":443",
   216  		grpc.WithTransportCredentials(credentials.NewTLS(nil)),
   217  		grpc.WithPerRPCCredentials(creds),
   218  		grpc.WithUserAgent(lucicfg.UserAgent),
   219  		grpc.WithDefaultServiceConfig(luciConfigRetryPolicy),
   220  	)
   221  	if err != nil {
   222  		return nil, errors.Annotate(err, "cannot dial to %s", host).Err()
   223  	}
   224  	return conn, nil
   225  }
   226  
   227  // Done is called as the last step of processing a subcommand.
   228  //
   229  // It dumps the command result (or an error) to the JSON output file, prints
   230  // the error message and generates the process exit code.
   231  func (c *Subcommand) Done(result any, err error) int {
   232  	err = c.writeJSONOutput(result, err)
   233  	if err != nil {
   234  		c.printError(err)
   235  		return 1
   236  	}
   237  	return 0
   238  }
   239  
   240  // printError prints an error to stderr.
   241  //
   242  // Recognizes various sorts of known errors and reports the appropriately.
   243  func (c *Subcommand) printError(err error) {
   244  	if _, ok := err.(CommandLineError); ok {
   245  		fmt.Fprintf(os.Stderr, "Bad command line: %s.\n\n", err)
   246  		c.Flags.Usage()
   247  	} else {
   248  		os.Stderr.WriteString(strings.Join(CollectErrorMessages(err, nil), "\n"))
   249  		os.Stderr.WriteString("\n")
   250  	}
   251  }
   252  
   253  // WriteJSONOutput writes result to JSON output file (if -json-output was set).
   254  //
   255  // If writing to the output file fails and the original error is nil, returns
   256  // the write error. If the original error is not nil, just logs the write error
   257  // and returns the original error.
   258  func (c *Subcommand) writeJSONOutput(result any, err error) error {
   259  	if c.jsonOutput == "" {
   260  		return err
   261  	}
   262  
   263  	// Note: this may eventually grow to include position in the *.star source
   264  	// code.
   265  	type detailedError struct {
   266  		Message string `json:"message"`
   267  	}
   268  	var output struct {
   269  		Generator string          `json:"generator"`        // lucicfg version
   270  		Error     string          `json:"error,omitempty"`  // overall error
   271  		Errors    []detailedError `json:"errors,omitempty"` // detailed errors
   272  		Result    any             `json:"result,omitempty"` // command-specific result
   273  	}
   274  	output.Generator = lucicfg.UserAgent
   275  	output.Result = result
   276  	if err != nil {
   277  		output.Error = err.Error()
   278  		for _, msg := range CollectErrorMessages(err, nil) {
   279  			output.Errors = append(output.Errors, detailedError{Message: msg})
   280  		}
   281  	}
   282  
   283  	// We don't want to create the file if we can't serialize. So serialize first.
   284  	// Also don't escape '<', it looks extremely ugly.
   285  	buf := bytes.Buffer{}
   286  	enc := json.NewEncoder(&buf)
   287  	enc.SetEscapeHTML(false)
   288  	enc.SetIndent("", "  ")
   289  	if e := enc.Encode(&output); e != nil {
   290  		if err == nil {
   291  			err = e
   292  		} else {
   293  			fmt.Fprintf(os.Stderr, "Failed to serialize JSON output: %s\n", e)
   294  		}
   295  		return err
   296  	}
   297  
   298  	if e := os.WriteFile(c.jsonOutput, buf.Bytes(), 0666); e != nil {
   299  		if err == nil {
   300  			err = e
   301  		} else {
   302  			fmt.Fprintf(os.Stderr, "Failed write JSON output to %s: %s\n", c.jsonOutput, e)
   303  		}
   304  	}
   305  	return err
   306  }