sigs.k8s.io/kubebuilder/v3@v3.14.0/pkg/cli/cli.go (about) 1 /* 2 Copyright 2020 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package cli 18 19 import ( 20 "errors" 21 "fmt" 22 "os" 23 "strings" 24 25 "github.com/spf13/afero" 26 "github.com/spf13/cobra" 27 "github.com/spf13/pflag" 28 29 "sigs.k8s.io/kubebuilder/v3/pkg/config" 30 yamlstore "sigs.k8s.io/kubebuilder/v3/pkg/config/store/yaml" 31 "sigs.k8s.io/kubebuilder/v3/pkg/machinery" 32 "sigs.k8s.io/kubebuilder/v3/pkg/model/stage" 33 "sigs.k8s.io/kubebuilder/v3/pkg/plugin" 34 ) 35 36 const ( 37 noticeColor = "\033[1;33m%s\033[0m" 38 deprecationFmt = "[Deprecation Notice] %s\n\n" 39 40 pluginsFlag = "plugins" 41 projectVersionFlag = "project-version" 42 ) 43 44 // CLI is the command line utility that is used to scaffold kubebuilder project files. 45 type CLI struct { //nolint:maligned 46 /* Fields set by Option */ 47 48 // Root command name. It is injected downstream to provide correct help, usage, examples and errors. 49 commandName string 50 // CLI version string. 51 version string 52 // CLI root's command description. 53 description string 54 // Plugins registered in the CLI. 55 plugins map[string]plugin.Plugin 56 // Default plugins in case none is provided and a config file can't be found. 57 defaultPlugins map[config.Version][]string 58 // Default project version in case none is provided and a config file can't be found. 59 defaultProjectVersion config.Version 60 // Commands injected by options. 61 extraCommands []*cobra.Command 62 // Alpha commands injected by options. 63 extraAlphaCommands []*cobra.Command 64 // Whether to add a completion command to the CLI. 65 completionCommand bool 66 67 /* Internal fields */ 68 69 // Plugin keys to scaffold with. 70 pluginKeys []string 71 // Project version to scaffold. 72 projectVersion config.Version 73 74 // A filtered set of plugins that should be used by command constructors. 75 resolvedPlugins []plugin.Plugin 76 77 // Root command. 78 cmd *cobra.Command 79 80 // Underlying fs 81 fs machinery.Filesystem 82 } 83 84 // New creates a new CLI instance. 85 // 86 // It follows the functional options pattern in order to customize the resulting CLI. 87 // 88 // It returns an error if any of the provided options fails. As some processing needs 89 // to be done, execution errors may be found here. Instead of returning an error, this 90 // function will return a valid CLI that errors in Run so that help is provided to the 91 // user. 92 func New(options ...Option) (*CLI, error) { 93 // Create the CLI. 94 c, err := newCLI(options...) 95 if err != nil { 96 return nil, err 97 } 98 99 // Build the cmd tree. 100 if err := c.buildCmd(); err != nil { 101 c.cmd.RunE = errCmdFunc(err) 102 return c, nil 103 } 104 105 // Add extra commands injected by options. 106 if err := c.addExtraCommands(); err != nil { 107 return nil, err 108 } 109 110 // Add extra alpha commands injected by options. 111 if err := c.addExtraAlphaCommands(); err != nil { 112 return nil, err 113 } 114 115 // Write deprecation notices after all commands have been constructed. 116 c.printDeprecationWarnings() 117 118 return c, nil 119 } 120 121 // newCLI creates a default CLI instance and applies the provided options. 122 // It is as a separate function for test purposes. 123 func newCLI(options ...Option) (*CLI, error) { 124 // Default CLI options. 125 c := &CLI{ 126 commandName: "kubebuilder", 127 description: `CLI tool for building Kubernetes extensions and tools. 128 `, 129 plugins: make(map[string]plugin.Plugin), 130 defaultPlugins: make(map[config.Version][]string), 131 fs: machinery.Filesystem{FS: afero.NewOsFs()}, 132 } 133 134 // Apply provided options. 135 for _, option := range options { 136 if err := option(c); err != nil { 137 return nil, err 138 } 139 } 140 141 return c, nil 142 } 143 144 // buildCmd creates the underlying cobra command and stores it internally. 145 func (c *CLI) buildCmd() error { 146 c.cmd = c.newRootCmd() 147 148 var uve config.UnsupportedVersionError 149 150 // Get project version and plugin keys. 151 switch err := c.getInfo(); { 152 case err == nil: 153 case errors.As(err, &uve) && uve.Version.Compare(config.Version{Number: 3, Stage: stage.Alpha}) == 0: 154 // Check if the corresponding stable version exists, set c.projectVersion and break 155 stableVersion := config.Version{ 156 Number: uve.Version.Number, 157 } 158 if config.IsRegistered(stableVersion) { 159 // Use the stableVersion 160 c.projectVersion = stableVersion 161 } else { 162 // stable version not registered, let's bail out 163 return err 164 } 165 default: 166 return err 167 } 168 169 // Resolve plugins for project version and plugin keys. 170 if err := c.resolvePlugins(); err != nil { 171 return err 172 } 173 174 // Add the subcommands 175 c.addSubcommands() 176 177 return nil 178 } 179 180 // getInfo obtains the plugin keys and project version resolving conflicts between the project config file and flags. 181 func (c *CLI) getInfo() error { 182 // Get plugin keys and project version from project configuration file 183 // We discard the error if file doesn't exist because not being able to read a project configuration 184 // file is not fatal for some commands. The ones that require it need to check its existence later. 185 hasConfigFile := true 186 if err := c.getInfoFromConfigFile(); errors.Is(err, os.ErrNotExist) { 187 hasConfigFile = false 188 } else if err != nil { 189 return err 190 } 191 192 // We can't early return here in case a project configuration file was found because 193 // this command call may override the project plugins. 194 195 // Get project version and plugin info from flags 196 if err := c.getInfoFromFlags(hasConfigFile); err != nil { 197 return err 198 } 199 200 // Get project version and plugin info from defaults 201 c.getInfoFromDefaults() 202 203 return nil 204 } 205 206 // getInfoFromConfigFile obtains the project version and plugin keys from the project config file. 207 func (c *CLI) getInfoFromConfigFile() error { 208 // Read the project configuration file 209 cfg := yamlstore.New(c.fs) 210 if err := cfg.Load(); err != nil { 211 return err 212 } 213 214 return c.getInfoFromConfig(cfg.Config()) 215 } 216 217 // getInfoFromConfig obtains the project version and plugin keys from the project config. 218 // It is extracted from getInfoFromConfigFile for testing purposes. 219 func (c *CLI) getInfoFromConfig(projectConfig config.Config) error { 220 c.pluginKeys = projectConfig.GetPluginChain() 221 c.projectVersion = projectConfig.GetVersion() 222 223 for _, pluginKey := range c.pluginKeys { 224 if err := plugin.ValidateKey(pluginKey); err != nil { 225 return fmt.Errorf("invalid plugin key found in project configuration file: %w", err) 226 } 227 } 228 229 return nil 230 } 231 232 // getInfoFromFlags obtains the project version and plugin keys from flags. 233 func (c *CLI) getInfoFromFlags(hasConfigFile bool) error { 234 // Partially parse the command line arguments 235 fs := pflag.NewFlagSet("base", pflag.ContinueOnError) 236 237 // Load the base command global flags 238 fs.AddFlagSet(c.cmd.PersistentFlags()) 239 240 // If we were unable to load the project configuration, we should also accept the project version flag 241 var projectVersionStr string 242 if !hasConfigFile { 243 fs.StringVar(&projectVersionStr, projectVersionFlag, "", "project version") 244 } 245 246 // FlagSet special cases --help and -h, so we need to create a dummy flag with these 2 values to prevent the default 247 // behavior (printing the usage of this FlagSet) as we want to print the usage message of the underlying command. 248 fs.BoolP("help", "h", false, fmt.Sprintf("help for %s", c.commandName)) 249 250 // Omit unknown flags to avoid parsing errors 251 fs.ParseErrorsWhitelist = pflag.ParseErrorsWhitelist{UnknownFlags: true} 252 253 // Parse the arguments 254 if err := fs.Parse(os.Args[1:]); err != nil { 255 return err 256 } 257 258 // If any plugin key was provided, replace those from the project configuration file 259 if pluginKeys, err := fs.GetStringSlice(pluginsFlag); err != nil { 260 return err 261 } else if len(pluginKeys) != 0 { 262 // Remove leading and trailing spaces and validate the plugin keys 263 for i, key := range pluginKeys { 264 pluginKeys[i] = strings.TrimSpace(key) 265 if err := plugin.ValidateKey(pluginKeys[i]); err != nil { 266 return fmt.Errorf("invalid plugin %q found in flags: %w", pluginKeys[i], err) 267 } 268 } 269 270 c.pluginKeys = pluginKeys 271 } 272 273 // If the project version flag was accepted but not provided keep the empty version and try to resolve it later, 274 // else validate the provided project version 275 if projectVersionStr != "" { 276 if err := c.projectVersion.Parse(projectVersionStr); err != nil { 277 return fmt.Errorf("invalid project version flag: %w", err) 278 } 279 } 280 281 return nil 282 } 283 284 // getInfoFromDefaults obtains the plugin keys, and maybe the project version from the default values 285 func (c *CLI) getInfoFromDefaults() { 286 // Should not use default values if a plugin was already set 287 // This checks includes the case where a project configuration file was found, 288 // as it will always have at least one plugin key set by now 289 if len(c.pluginKeys) != 0 { 290 // We don't assign a default value for project version here because we may be able to 291 // resolve the project version after resolving the plugins. 292 return 293 } 294 295 // If the user provided a project version, use the default plugins for that project version 296 if c.projectVersion.Validate() == nil { 297 c.pluginKeys = c.defaultPlugins[c.projectVersion] 298 return 299 } 300 301 // Else try to use the default plugins for the default project version 302 if c.defaultProjectVersion.Validate() == nil { 303 var found bool 304 if c.pluginKeys, found = c.defaultPlugins[c.defaultProjectVersion]; found { 305 c.projectVersion = c.defaultProjectVersion 306 return 307 } 308 } 309 310 // Else check if only default plugins for a project version were provided 311 if len(c.defaultPlugins) == 1 { 312 for projectVersion, defaultPlugins := range c.defaultPlugins { 313 c.pluginKeys = defaultPlugins 314 c.projectVersion = projectVersion 315 return 316 } 317 } 318 } 319 320 const unstablePluginMsg = " (plugin version is unstable, there may be an upgrade available: " + 321 "https://kubebuilder.io/migration/plugin/plugins.html)" 322 323 // resolvePlugins selects from the available plugins those that match the project version and plugin keys provided. 324 func (c *CLI) resolvePlugins() error { 325 knownProjectVersion := c.projectVersion.Validate() == nil 326 327 for _, pluginKey := range c.pluginKeys { 328 var extraErrMsg string 329 330 plugins := make([]plugin.Plugin, 0, len(c.plugins)) 331 for _, p := range c.plugins { 332 plugins = append(plugins, p) 333 } 334 // We can omit the error because plugin keys have already been validated 335 plugins, _ = plugin.FilterPluginsByKey(plugins, pluginKey) 336 if knownProjectVersion { 337 plugins = plugin.FilterPluginsByProjectVersion(plugins, c.projectVersion) 338 extraErrMsg += fmt.Sprintf(" for project version %q", c.projectVersion) 339 } 340 341 // Plugins are often released as "unstable" (alpha/beta) versions, then upgraded to "stable". 342 // This upgrade effectively removes a plugin, which is fine because unstable plugins are 343 // under no support contract. However users should be notified _why_ their plugin cannot be found. 344 if _, version := plugin.SplitKey(pluginKey); version != "" { 345 var ver plugin.Version 346 if err := ver.Parse(version); err != nil { 347 return fmt.Errorf("error parsing input plugin version from key %q: %v", pluginKey, err) 348 } 349 if !ver.IsStable() { 350 extraErrMsg += unstablePluginMsg 351 } 352 } 353 354 // Only 1 plugin can match 355 switch len(plugins) { 356 case 1: 357 c.resolvedPlugins = append(c.resolvedPlugins, plugins[0]) 358 case 0: 359 return fmt.Errorf("no plugin could be resolved with key %q%s", pluginKey, extraErrMsg) 360 default: 361 return fmt.Errorf("ambiguous plugin %q%s", pluginKey, extraErrMsg) 362 } 363 } 364 365 // Now we can try to resolve the project version if not known by this point 366 if !knownProjectVersion && len(c.resolvedPlugins) > 0 { 367 // Extract the common supported project versions 368 supportedProjectVersions := plugin.CommonSupportedProjectVersions(c.resolvedPlugins...) 369 370 // If there is only one common supported project version, resolve to it 371 ProjectNumberVersionSwitch: 372 switch len(supportedProjectVersions) { 373 case 1: 374 c.projectVersion = supportedProjectVersions[0] 375 case 0: 376 return fmt.Errorf("no project version supported by all the resolved plugins") 377 default: 378 supportedProjectVersionStrings := make([]string, 0, len(supportedProjectVersions)) 379 for _, supportedProjectVersion := range supportedProjectVersions { 380 // In case one of the multiple supported versions is the default one, choose that and exit the switch 381 if supportedProjectVersion.Compare(c.defaultProjectVersion) == 0 { 382 c.projectVersion = c.defaultProjectVersion 383 break ProjectNumberVersionSwitch 384 } 385 supportedProjectVersionStrings = append(supportedProjectVersionStrings, 386 fmt.Sprintf("%q", supportedProjectVersion)) 387 } 388 return fmt.Errorf("ambiguous project version, resolved plugins support the following project versions: %s", 389 strings.Join(supportedProjectVersionStrings, ", ")) 390 } 391 } 392 393 return nil 394 } 395 396 // addSubcommands returns a root command with a subcommand tree reflecting the 397 // current project's state. 398 func (c *CLI) addSubcommands() { 399 // add the alpha command if it has any subcommands enabled 400 c.addAlphaCmd() 401 402 // kubebuilder completion 403 // Only add completion if requested 404 if c.completionCommand { 405 c.cmd.AddCommand(c.newCompletionCmd()) 406 } 407 408 // kubebuilder create 409 createCmd := c.newCreateCmd() 410 // kubebuilder create api 411 createCmd.AddCommand(c.newCreateAPICmd()) 412 createCmd.AddCommand(c.newCreateWebhookCmd()) 413 if createCmd.HasSubCommands() { 414 c.cmd.AddCommand(createCmd) 415 } 416 417 // kubebuilder edit 418 c.cmd.AddCommand(c.newEditCmd()) 419 420 // kubebuilder init 421 c.cmd.AddCommand(c.newInitCmd()) 422 423 // kubebuilder version 424 // Only add version if a version string was provided 425 if c.version != "" { 426 c.cmd.AddCommand(c.newVersionCmd()) 427 } 428 } 429 430 // addExtraCommands adds the additional commands. 431 func (c *CLI) addExtraCommands() error { 432 for _, cmd := range c.extraCommands { 433 for _, subCmd := range c.cmd.Commands() { 434 if cmd.Name() == subCmd.Name() { 435 return fmt.Errorf("command %q already exists", cmd.Name()) 436 } 437 } 438 c.cmd.AddCommand(cmd) 439 } 440 return nil 441 } 442 443 // printDeprecationWarnings prints the deprecation warnings of the resolved plugins. 444 func (c CLI) printDeprecationWarnings() { 445 for _, p := range c.resolvedPlugins { 446 if p != nil && p.(plugin.Deprecated) != nil && len(p.(plugin.Deprecated).DeprecationWarning()) > 0 { 447 fmt.Fprintf(os.Stderr, noticeColor, fmt.Sprintf(deprecationFmt, p.(plugin.Deprecated).DeprecationWarning())) 448 } 449 } 450 } 451 452 // metadata returns CLI's metadata. 453 func (c CLI) metadata() plugin.CLIMetadata { 454 return plugin.CLIMetadata{ 455 CommandName: c.commandName, 456 } 457 } 458 459 // Run executes the CLI utility. 460 // 461 // If an error is found, command help and examples will be printed. 462 func (c CLI) Run() error { 463 return c.cmd.Execute() 464 }