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 = ¶ms 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 }