Skip to content

Commit 329b4be

Browse files
Extend contribution guidelines (new commands, services, verbosity) (#202)
* Extend contribution guidelines (new commands, services, verbosity) * Remove leftover * Fix make command call * Update CONTRIBUTION.md Co-authored-by: Vicente Pinto <[email protected]> * Update CONTRIBUTION.md Co-authored-by: Vicente Pinto <[email protected]> * Update CONTRIBUTION.md Co-authored-by: Vicente Pinto <[email protected]> * Update CONTRIBUTION.md Co-authored-by: Vicente Pinto <[email protected]> * Update CONTRIBUTION.md Co-authored-by: Vicente Pinto <[email protected]> * Adjustments after review --------- Co-authored-by: Vicente Pinto <[email protected]>
1 parent d2e8240 commit 329b4be

File tree

1 file changed

+216
-10
lines changed

1 file changed

+216
-10
lines changed

CONTRIBUTION.md

Lines changed: 216 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ Your contribution is welcome! Thank you for your interest in contributing to the
55
## Table of contents
66

77
- [Developer Guide](#developer-guide)
8+
- [Useful Make commands](#useful-make-commands)
9+
- [Repository structure](#repository-structure)
10+
- [Implementing a new command](#implementing-a-new-command)
11+
- [Command file structure](#command-file-structure)
12+
- [Outputs, prints and debug logs](#outputs-prints-and-debug-logs)
13+
- [Onboarding a new STACKIT service](#onboarding-a-new-stackit-service)
14+
- [Local development](#local-development)
815
- [Code Contributions](#code-contributions)
916
- [Bug Reports](#bug-reports)
1017

@@ -15,15 +22,7 @@ Prerequisites:
1522
- [`Go`](https://go.dev/doc/install) 1.22+
1623
- [`yamllint`](https://yamllint.readthedocs.io/en/stable/quickstart.html)
1724

18-
### Repository structure
19-
20-
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.
21-
22-
### Getting started
23-
24-
Check the [Authentication](README.md#authentication) section on the README.
25-
26-
#### Useful Make commands
25+
### Useful Make commands
2726

2827
These commands can be executed from the project root:
2928

@@ -33,7 +32,214 @@ These commands can be executed from the project root:
3332
- `make generate-docs`: generate Markdown documentation for every command
3433
- `make test`: run unit tests
3534

36-
#### Local development
35+
### Repository structure
36+
37+
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.
38+
39+
### Implementing a new command
40+
41+
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`):
42+
43+
1. You would start by creating a new folder `bar/` inside `internal/cmd/foo/`
44+
2. Following with the creation of a file `bar.go` inside your new folder `internal/cmd/foo/bar/`
45+
1. The Go package should be similar to the command usage, in this case `package bar` would be an adequate name
46+
2. Please refer to the [Command file structure](./CONTRIBUTION.md/#command-file-structure) section for details on the strcutre of the file itself
47+
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
48+
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`)
49+
50+
Please remeber to run `make generate-docs` after your changes to keep the commands' documentation updated.
51+
52+
#### Command file structure
53+
54+
Below is a typical structure of a CLI command:
55+
56+
```go
57+
package bar
58+
59+
import (
60+
(...)
61+
)
62+
63+
// Define consts for command flags
64+
const (
65+
someArg = "MY_ARG"
66+
someFlag = "my-flag"
67+
)
68+
69+
// Struct to model user input (arguments and/or flags)
70+
type inputModel struct {
71+
*globalflags.GlobalFlagModel
72+
MyArg string
73+
MyFlag *string
74+
}
75+
76+
// "bar" command constructor
77+
func NewCmd() *cobra.Command {
78+
cmd := &cobra.Command{
79+
Use: "bar",
80+
Short: "Short description of the command (is shown in the help of parent command)",
81+
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.",
82+
Args: args.SingleArg(someArg, utils.ValidateUUID), // Validate argument, with an optional validation function
83+
Example: examples.Build(
84+
examples.NewExample(
85+
`Do something with command "bar"`,
86+
"$ stackit foo bar arg-value --my-flag flag-value"),
87+
...
88+
),
89+
RunE: func(cmd *cobra.Command, args []string) error {
90+
ctx := context.Background()
91+
model, err := parseInput(cmd, args)
92+
if err != nil {
93+
return err
94+
}
95+
96+
// Configure API client
97+
apiClient, err := client.ConfigureClient(cmd)
98+
if err != nil {
99+
return err
100+
}
101+
102+
// Call API
103+
req := buildRequest(ctx, model, apiClient)
104+
resp, err := req.Execute()
105+
if err != nil {
106+
return fmt.Errorf("(...): %w", err)
107+
}
108+
109+
projectLabel, err := projectname.GetProjectName(ctx, cmd)
110+
if err != nil {
111+
projectLabel = model.ProjectId
112+
}
113+
114+
// Check API response "resp" and output accordingly
115+
if resp.Item == nil {
116+
p.Info("(...)", projectLabel)
117+
return nil
118+
}
119+
return outputResult(cmd, model.OutputFormat, instances)
120+
},
121+
}
122+
123+
configureFlags(cmd)
124+
return cmd
125+
}
126+
127+
// Configure command flags (type, default value, and description)
128+
func configureFlags(cmd *cobra.Command) {
129+
cmd.Flags().StringP(myFlag, "defaultValue", "My flag description")
130+
}
131+
132+
// Parse user input (arguments and/or flags)
133+
func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
134+
myArg := inputArgs[0]
135+
136+
globalFlags := globalflags.Parse(cmd)
137+
if globalFlags.ProjectId == "" {
138+
return nil, &errors.ProjectIdError{}
139+
}
140+
141+
return &inputModel{
142+
GlobalFlagModel: globalFlags,
143+
MyArg myArg,
144+
MyFlag: flags.FlagToStringPointer(cmd, myFlag),
145+
}, nil
146+
}
147+
148+
// Build request to the API
149+
func buildRequest(ctx context.Context, model *inputModel, apiClient *foo.APIClient) foo.ApiListInstancesRequest {
150+
req := apiClient.GetBar(ctx, model.ProjectId, model.MyArg, someParam)
151+
return req
152+
}
153+
154+
// Output result based on the configured output format
155+
func outputResult(cmd *cobra.Command, outputFormat string, resources []foo.Resource) error {
156+
switch outputFormat {
157+
case globalflags.JSONOutputFormat:
158+
details, err := json.MarshalIndent(resources, "", " ")
159+
if err != nil {
160+
return fmt.Errorf("marshal resource list: %w", err)
161+
}
162+
p.Outputln(string(details))
163+
return nil
164+
default:
165+
table := tables.NewTable()
166+
table.SetHeader("ID", "NAME", "STATE")
167+
for i := range resources {
168+
resource := resources[i]
169+
table.AddRow(*resource.ResourceId, *resource.Name, *resource.State)
170+
}
171+
err := table.Display(cmd)
172+
if err != nil {
173+
return fmt.Errorf("render table: %w", err)
174+
}
175+
return nil
176+
}
177+
}
178+
```
179+
180+
Please remeber to always add unit tests for `parseInput`, `buildRequest` (in `bar_test.go`), and any other util functions used.
181+
182+
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).
183+
184+
#### Outputs, prints and debug logs
185+
186+
The CLI has 4 different verbosity levels:
187+
188+
- `error`: For only displaying errors
189+
- `warning`: For displaying user facing warnings _(and all of the above)_
190+
- `info` (default): For displaying user facing info, such as operation success messages and spinners _(and all of the above)_
191+
- `debug`: For displaying structured logs with different levels, including errors _(and all of the above)_
192+
193+
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`.
194+
195+
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_".
196+
197+
### Onboarding a new STACKIT service
198+
199+
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:
200+
201+
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`)
202+
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
203+
3. Setup the SDK client configuration, using the authentication method configured in the CLI
204+
205+
1. This is done in `internal/pkg/services/foo/client/client.go`
206+
2. Below is an example of a typical `client.go` file structure:
207+
208+
```go
209+
package client
210+
211+
import (
212+
(...)
213+
"github.com/stackitcloud/stackit-sdk-go/services/foo"
214+
)
215+
216+
func ConfigureClient(cmd *cobra.Command) (*foo.APIClient, error) {
217+
var err error
218+
var apiClient foo.APIClient
219+
var cfgOptions []sdkConfig.ConfigurationOption
220+
221+
authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser)
222+
if err != nil {
223+
return nil, &errors.AuthError{}
224+
}
225+
cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01")) // Configuring region is needed if "foo" is a regional API
226+
227+
customEndpoint := viper.GetString(config.fooCustomEndpointKey)
228+
229+
if customEndpoint != "" {
230+
cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint))
231+
}
232+
233+
apiClient, err = foo.NewAPIClient(cfgOptions...)
234+
if err != nil {
235+
return nil, &errors.AuthError{}
236+
}
237+
238+
return apiClient, nil
239+
}
240+
```
241+
242+
### Local development
37243

38244
To test your changes, you can either:
39245

0 commit comments

Comments
 (0)