## Contributing Thanks for your interest in contributing to the Confluent CLI! ### Development Environment Start by following these steps to set up your computer for CLI development: #### Go Version We recommend you use [goenv](https://github.com/syndbg/goenv) to manage your Go versions. There's a `.go-version` file in this repo with the exact version we use (and test against in CI). We recommend cloning the `goenv` repo directly to ensure that you have access to the latest version of Go. If you've already installed `goenv` with brew, uninstall it first: brew uninstall goenv Now, clone the `goenv` repo: git clone https://github.com/syndbg/goenv.git ~/.goenv Then, add the following to your shell profile: export GOENV_ROOT="$HOME/.goenv" export PATH="$GOENV_ROOT/bin:$PATH" eval "$(goenv init -)" export PATH="$PATH:$GOPATH/bin" Finally, you can install the appropriate version of Go by running the following command inside the root directory of the repository: goenv install #### Developing on MacOS Our integration tests read a lot of files while they are running. On MacOS, the default maximum number of open files is 256, which is too small (you will see an error like `error retrieving command exit code` or `too many open files`). Please run the following three commands *and then restart* for these changes to take effect: echo 'kern.maxfiles=20480' | sudo tee -a /etc/sysctl.conf echo -e 'limit maxfiles 8192 20480\nlimit maxproc 1000 2000' | sudo tee -a /etc/launchd.conf echo 'ulimit -n 4096' | sudo tee -a /etc/profile #### Security We use `pre-commit` hooks and `gitleaks` to prevent secrets from being committed to this repo. Please install `pre-commit` hooks (Note that the second command should be run inside the root directory of the repository): brew install pre-commit pre-commit install ### File Layout This repo mostly follows the [Standard Go Project Layout](https://github.com/golang-standards/project-layout). Here's the basic file structure: cli/ ├─ cmd/ │ ├─ confluent/ │ │ ├─ main.go (entry point for the CLI binary) ├─ dist/ │ ├─ confluent__/ │ │ ├─ confluent (the CLI binary) ├─ internal/ │ ├─ cmd/ (CLI commands) │ │ ├─ / │ │ │ ├─ command.go (a top-level CLI command) │ │ │ ├─ command_.go (a subcommand of a top-level CLI command) │ │ │ ├─ command__onprem.go (the on-prem version of the above command, if applicable) │ │ ├─ command.go (the root CLI command) │ ├─ pkg/ ├─ test/ (integration tests) │ ├─ fixtures/ │ │ ├─ output/ │ │ │ ├─ / (the golden files for a top-level CLI command) │ │ ├─ cli_test.go (entry point for all integration tests) │ │ ├─ _test.go (the integration tests for a top-level CLI command) ### Testing The CLI is tested with a combination of unit tests and integration tests. To run all tests: make test #### Unit Tests Unit tests exist in files ending with `_test.go`, and are located alongside the main source code files. Unit tests should test small, isolated functions, and should not be unnecessarily complex (i.e. mocking backend calls or CLI commands). To run the all unit tests: make unit-test To run a subset of unit tests, you must specify the suite and optionally the name of a specific tests: # Run a suite of unit tests make unit-test UNIT_TEST_ARGS="-run TestApiTestSuite" # Run a specific unit test within a suite make unit-test UNIT_TEST_ARGS="-run TestApiTestSuite/TestCreateCloudAPIKey" #### Integration Tests The [test/](./test) directory contains our integration tests. These tests build the CLI binary and invoke commands on it. These CLI integration tests roughly follow this [pattern](http://lucapette.me/writing-integration-tests-for-a-go-cli-application): 1. Run a test HTTP server to mock Confluent Cloud or the Confluent Platform Control Plane API. 2. Run a logical sequence of CLI commands. 3. Ensure that the output of these commands matches the corresponding golden files. To update the golden files from the current output: make integration-test INTEGRATION_TEST_ARGS="-update" To skip rebuilding the CLI, if it already exists in `dist/`: make integration-test INTEGRATION_TEST_ARGS="-no-rebuild" To run a subset of integration tests, you must specify the suite and optionally the name of a specific test: # Run a suite of integration tests make integration-test INTEGRATION_TEST_ARGS="-run TestCLI/TestKafka" # Run a specific integration test within a suite make integration-test INTEGRATION_TEST_ARGS="-run TestCLI/TestKafka/kafka_cluster_--help" ### Example: Adding a New Command to the CLI As a basic demonstration, we'll be implementing a command which prints the name of the CLI config file a specified number of times: $ confluent config describe 3 ~/.confluent/config.json ~/.confluent/config.json ~/.confluent/config.json #### Creating the Command Like all other commands, this command will reside in `internal`. First, we must create a directory for this command: mkdir internal/config Next, we create two files, one for the top-level command `config`, and another for `describe`. `internal/config/command.go`: ```go package config import ( "github.com/spf13/cobra" pcmd "github.com/confluentinc/cli/v4/pkg/cmd" ) type command struct { *pcmd.CLICommand } func New(prerunner pcmd.PreRunner) *cobra.Command { cmd := &cobra.Command{Use: "config"} c := &command{pcmd.NewAnonymousCLICommand(cmd, prerunner)} cmd.AddCommand(c.newDescribeCommand()) return cmd } ``` `internal/config/command_describe.go`: ```go package config import ( "strconv" "github.com/spf13/cobra" "github.com/confluentinc/cli/v4/pkg/errors" "github.com/confluentinc/cli/v4/pkg/utils" ) func (c *command) newDescribeCommand() *cobra.Command { return &cobra.Command{ Use: "describe", Args: cobra.ExactArgs(1), RunE: c.describe, } } func (c *command) describe(_ *cobra.Command, args []string) error { filename := c.CLICommand.Config.Config.Filename if filename == "" { return fmt.Errorf("config file not found") } n, err := strconv.Atoi(args[0]) if err != nil { return err } for i := 0; i < n; i++ { output.Println(filename) } return nil } ``` #### Registering the Command Finally, we must add the newly created `config` command as a child of the root command. Add the following line to `internal/command.go`, and make sure to import its package: cmd.AddCommand(config.New(prerunner)) #### Running the Command To build the CLI binary, run `make build`. After this, we can run our command in the following way, and see that it (hopefully) works! make build dist/confluent__/confluent config file describe 3 #### Integration Testing There's not much code here to unit test, so we'll skip right to integration testing. Create the following file: `test/config/config_test.go`: ```go package test func (s *CLITestSuite) TestConfigDescribe() { tests := []CLITest{ {args: "config describe 3", fixture: "config/1.golden"}, } for _, test := range tests { s.runConfluentTest(test) } } ``` We'll also need to add the new golden file, `test/fixtures/output/config/1.golden`. After running the command manually to ensure the output is correct, the content for the golden file can either be: 1. Copied directly from the terminal. 2. Updated automatically with `make integration-test INTEGRATION_TEST_ARGS="-run TestCLI/TestConfigDescribe -update"` (slow). Now, run `make integration-test INTEGRATION_TEST_ARGS="-run TestCLI/TestConfigDescribe"` and verify that it works! #### Add Autocompletion Add support for autocompletion using `ValidArgsFunction` if applicable (for example, if the command takes resource IDs or resource names as arguments): ```go func (c *command) newDescribeCommand() *cobra.Command { return &cobra.Command{ Use: "describe", Args: cobra.ExactArgs(1), ValidArgsFunction: pcmd.NewValidArgsFunction(c.validArgs), RunE: c.describe, } } ``` See the [Autocompletion](pkg/cmd/AUTOCOMPLETION.md) resource for implementation details. #### Adding a New Delete Command For most resource types, a `delete` command should support multiple arguments. The exceptions are resources which do not have an ID (e.g. ACLs, role bindings) or unique resources (e.g. the Schema Registry cluster). See [Supporting Multiple Deletion](pkg/deletion/README.md) for instructions on how to write such commands. ### Opening a PR That's it! As you can see, the process of adding a new CLI command is pretty straightforward. You can open a PR if: * You're able to build the CLI with `make build`. * All unit and integration test pass with `make test`. * Running `make lint` produces no linter errors. Note: If there is a JIRA ticket associated with your PR, please format the PR description as "[CLI-1234] Description of PR". ### Breaking Changes When contributing, avoid making breaking changes outside of major version releases. We consider a breaking change to be any change that could impact a user's workflow. The following are examples of breaking changes: * Removing an output field * Changing an output field's serialized name * Renaming or removing a command * Renaming or removing a flag * Changing the output format (e.g. table to list) * Increasing the GLIBC version requirement for Linux versions This is not an exhaustive list. Always consider the potential user impact of any change! Note: For the `confluent local services` command, which interacts with Confluent Platform, we should avoid breaking changes for all supported versions of Confluent Platform. The following may seem like breaking changes, but *aren't*: * Renaming a non-serialized, human-readable field name * Hiding a deprecated flag (as long as users can still use the flag!) These criteria apply to commands, flags, or any functionality in the LA (Limited Availability) or GA (General Availability) lifecycle stages, which should have a stable interface. Functionality in the EA (Early Access) or OP (Open Preview) lifecycle stage *are* allowed to have breaking changes, though we should still try to avoid them if possible for Open Preview features. ### Detailed Implementation Guides Please familiarize yourself with the following resources before writing your first CLI command: * [Autocompletion](pkg/cmd/AUTOCOMPLETION.md) * [Cloud and On-Prem Annotations](pkg/cmd/ANNOTATIONS.md) * [Errors](pkg/errors/README.md) * [Output](pkg/output/README.md)