mygithub.libinneed.workers.dev/stackitcloud/stackit-cli
Advanced tools
+216
-10
@@ -8,2 +8,9 @@ # Contribute to the STACKIT CLI | ||
| - [Developer Guide](#developer-guide) | ||
| - [Useful Make commands](#useful-make-commands) | ||
| - [Repository structure](#repository-structure) | ||
| - [Implementing a new command](#implementing-a-new-command) | ||
| - [Command file structure](#command-file-structure) | ||
| - [Outputs, prints and debug logs](#outputs-prints-and-debug-logs) | ||
| - [Onboarding a new STACKIT service](#onboarding-a-new-stackit-service) | ||
| - [Local development](#local-development) | ||
| - [Code Contributions](#code-contributions) | ||
@@ -19,12 +26,4 @@ - [Bug Reports](#bug-reports) | ||
| ### Repository structure | ||
| ### Useful Make commands | ||
| The CLI commands are located under `internal/cmd`, where each folder includes the source code for a `group` of commands. Inside `pkg` you can find several useful packages that are shared by the commands and provide additional functionality such as `flags`, `globalflags`, `tables`, etc. | ||
| ### Getting started | ||
| Check the [Authentication](README.md#authentication) section on the README. | ||
| #### Useful Make commands | ||
| These commands can be executed from the project root: | ||
@@ -38,4 +37,211 @@ | ||
| #### Local development | ||
| ### Repository structure | ||
| The CLI commands are located under `internal/cmd`, where each folder includes the source code for each subcommand (including their own subcommands). Inside `pkg` you can find several useful packages that are shared by the commands and provide additional functionality such as `flags`, `globalflags`, `tables`, etc. | ||
| ### Implementing a new command | ||
| Let's suppose you want to want to implement a new command `bar`, that would be the direct child of an existing command `stackit foo` (meaning it would be invoked as `stackit foo bar`): | ||
| 1. You would start by creating a new folder `bar/` inside `internal/cmd/foo/` | ||
| 2. Following with the creation of a file `bar.go` inside your new folder `internal/cmd/foo/bar/` | ||
| 1. The Go package should be similar to the command usage, in this case `package bar` would be an adequate name | ||
| 2. Please refer to the [Command file structure](./CONTRIBUTION.md/#command-file-structure) section for details on the strcutre of the file itself | ||
| 3. To register the command `bar` as a child of the existing command `foo`, add `cmd.AddCommand(bar.NewCmd(p))` to the `addSubcommands` method of the constructor of the `foo` command | ||
| 1. In this case, `p` is the `printer` that is passed from the root command to all subcommands of the tree (refer to the [Outputs, prints and debug logs](./CONTRIBUTION.md/#outputs-prints-and-debug-logs) section for more details regarding the `printer`) | ||
| Please remeber to run `make generate-docs` after your changes to keep the commands' documentation updated. | ||
| #### Command file structure | ||
| Below is a typical structure of a CLI command: | ||
| ```go | ||
| package bar | ||
| import ( | ||
| (...) | ||
| ) | ||
| // Define consts for command flags | ||
| const ( | ||
| someArg = "MY_ARG" | ||
| someFlag = "my-flag" | ||
| ) | ||
| // Struct to model user input (arguments and/or flags) | ||
| type inputModel struct { | ||
| *globalflags.GlobalFlagModel | ||
| MyArg string | ||
| MyFlag *string | ||
| } | ||
| // "bar" command constructor | ||
| func NewCmd() *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "bar", | ||
| Short: "Short description of the command (is shown in the help of parent command)", | ||
| Long: "Long description of the command. Can contain some more information about the command usage. It is shown in the help of the current command.", | ||
| Args: args.SingleArg(someArg, utils.ValidateUUID), // Validate argument, with an optional validation function | ||
| Example: examples.Build( | ||
| examples.NewExample( | ||
| `Do something with command "bar"`, | ||
| "$ stackit foo bar arg-value --my-flag flag-value"), | ||
| ... | ||
| ), | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| ctx := context.Background() | ||
| model, err := parseInput(cmd, args) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| // Configure API client | ||
| apiClient, err := client.ConfigureClient(cmd) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| // Call API | ||
| req := buildRequest(ctx, model, apiClient) | ||
| resp, err := req.Execute() | ||
| if err != nil { | ||
| return fmt.Errorf("(...): %w", err) | ||
| } | ||
| projectLabel, err := projectname.GetProjectName(ctx, cmd) | ||
| if err != nil { | ||
| projectLabel = model.ProjectId | ||
| } | ||
| // Check API response "resp" and output accordingly | ||
| if resp.Item == nil { | ||
| p.Info("(...)", projectLabel) | ||
| return nil | ||
| } | ||
| return outputResult(cmd, model.OutputFormat, instances) | ||
| }, | ||
| } | ||
| configureFlags(cmd) | ||
| return cmd | ||
| } | ||
| // Configure command flags (type, default value, and description) | ||
| func configureFlags(cmd *cobra.Command) { | ||
| cmd.Flags().StringP(myFlag, "defaultValue", "My flag description") | ||
| } | ||
| // Parse user input (arguments and/or flags) | ||
| func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { | ||
| myArg := inputArgs[0] | ||
| globalFlags := globalflags.Parse(cmd) | ||
| if globalFlags.ProjectId == "" { | ||
| return nil, &errors.ProjectIdError{} | ||
| } | ||
| return &inputModel{ | ||
| GlobalFlagModel: globalFlags, | ||
| MyArg myArg, | ||
| MyFlag: flags.FlagToStringPointer(cmd, myFlag), | ||
| }, nil | ||
| } | ||
| // Build request to the API | ||
| func buildRequest(ctx context.Context, model *inputModel, apiClient *foo.APIClient) foo.ApiListInstancesRequest { | ||
| req := apiClient.GetBar(ctx, model.ProjectId, model.MyArg, someParam) | ||
| return req | ||
| } | ||
| // Output result based on the configured output format | ||
| func outputResult(cmd *cobra.Command, outputFormat string, resources []foo.Resource) error { | ||
| switch outputFormat { | ||
| case globalflags.JSONOutputFormat: | ||
| details, err := json.MarshalIndent(resources, "", " ") | ||
| if err != nil { | ||
| return fmt.Errorf("marshal resource list: %w", err) | ||
| } | ||
| p.Outputln(string(details)) | ||
| return nil | ||
| default: | ||
| table := tables.NewTable() | ||
| table.SetHeader("ID", "NAME", "STATE") | ||
| for i := range resources { | ||
| resource := resources[i] | ||
| table.AddRow(*resource.ResourceId, *resource.Name, *resource.State) | ||
| } | ||
| err := table.Display(cmd) | ||
| if err != nil { | ||
| return fmt.Errorf("render table: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
| ``` | ||
| Please remeber to always add unit tests for `parseInput`, `buildRequest` (in `bar_test.go`), and any other util functions used. | ||
| If the new command `bar` is the first command in the CLI using a STACKIT service `foo`, please refer to [Onboarding a new STACKIT service](./CONTRIBUTION.md/#onboarding-a-new-stackit-service). | ||
| #### Outputs, prints and debug logs | ||
| The CLI has 4 different verbosity levels: | ||
| - `error`: For only displaying errors | ||
| - `warning`: For displaying user facing warnings _(and all of the above)_ | ||
| - `info` (default): For displaying user facing info, such as operation success messages and spinners _(and all of the above)_ | ||
| - `debug`: For displaying structured logs with different levels, including errors _(and all of the above)_ | ||
| For prints that are specific to a certain log level, you can use the methods defined in the `print` package: `Error`, `Warn`, `Info`, and `Debug`. | ||
| For command outputs that should always be displayed, no matter the defined verbosity, you should use the `print` methods `Outputf` and `Outputln`. These should only be used for the actual output of the commands, which can usually be described by "I ran the command to see _this_". | ||
| ### Onboarding a new STACKIT service | ||
| If you want to add a command that uses a STACKIT service `foo` that was not yet used by the CLI, you will first need to implement a few extra steps to configure the new service: | ||
| 1. Add a `FooCustomEndpointKey` key in `internal/pkg/config/config.go` (and add it to `ConfigKeys` and set the to default to `""` using `viper.SetDefault`) | ||
| 2. Update the `stackit config unset` and `stackit config unset` commands by adding flags to set and unset a custom endpoint for the `foo` service API, respectively, and update their unit tests | ||
| 3. Setup the SDK client configuration, using the authentication method configured in the CLI | ||
| 1. This is done in `internal/pkg/services/foo/client/client.go` | ||
| 2. Below is an example of a typical `client.go` file structure: | ||
| ```go | ||
| package client | ||
| import ( | ||
| (...) | ||
| "github.com/stackitcloud/stackit-sdk-go/services/foo" | ||
| ) | ||
| func ConfigureClient(cmd *cobra.Command) (*foo.APIClient, error) { | ||
| var err error | ||
| var apiClient foo.APIClient | ||
| var cfgOptions []sdkConfig.ConfigurationOption | ||
| authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) | ||
| if err != nil { | ||
| return nil, &errors.AuthError{} | ||
| } | ||
| cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01")) // Configuring region is needed if "foo" is a regional API | ||
| customEndpoint := viper.GetString(config.fooCustomEndpointKey) | ||
| if customEndpoint != "" { | ||
| cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) | ||
| } | ||
| apiClient, err = foo.NewAPIClient(cfgOptions...) | ||
| if err != nil { | ||
| return nil, &errors.AuthError{} | ||
| } | ||
| return apiClient, nil | ||
| } | ||
| ``` | ||
| ### Local development | ||
| To test your changes, you can either: | ||
@@ -42,0 +248,0 @@ |
+0
-13
@@ -94,3 +94,2 @@ package cmd | ||
| cmd.AddCommand(argus.NewCmd(p)) | ||
| cmd.AddCommand(argus.NewCmd(p)) | ||
| cmd.AddCommand(auth.NewCmd(p)) | ||
@@ -107,9 +106,2 @@ cmd.AddCommand(config.NewCmd(p)) | ||
| cmd.AddCommand(postgresflex.NewCmd(p)) | ||
| cmd.AddCommand(logme.NewCmd(p)) | ||
| cmd.AddCommand(mariadb.NewCmd(p)) | ||
| cmd.AddCommand(mongodbflex.NewCmd(p)) | ||
| cmd.AddCommand(objectstorage.NewCmd(p)) | ||
| cmd.AddCommand(opensearch.NewCmd(p)) | ||
| cmd.AddCommand(organization.NewCmd(p)) | ||
| cmd.AddCommand(postgresflex.NewCmd(p)) | ||
| cmd.AddCommand(project.NewCmd(p)) | ||
@@ -121,7 +113,2 @@ cmd.AddCommand(rabbitmq.NewCmd(p)) | ||
| cmd.AddCommand(ske.NewCmd(p)) | ||
| cmd.AddCommand(rabbitmq.NewCmd(p)) | ||
| cmd.AddCommand(redis.NewCmd(p)) | ||
| cmd.AddCommand(secretsmanager.NewCmd(p)) | ||
| cmd.AddCommand(serviceaccount.NewCmd(p)) | ||
| cmd.AddCommand(ske.NewCmd(p)) | ||
| } | ||
@@ -128,0 +115,0 @@ |