This directory contains some documentation about the hcp
CLI codebase, aimed at
readers who are interested in making code contributions.
If you're looking for information on using hcp
, please instead refer to the
HCP CLI Documentation.
To build the CLI and output the binary to the bin/
directory, run:
make go/build
To install the CLI to your GOPATH, run:
make go/install
To run the tests, run:
make go/test
make go/lint
To add a new mock, edit .mockery.yaml
and add the package you want mocked. To
regenerate the mocks, either after adding a new mock package or after updating
the dependencies, run:
# mockery version >= v2.38.0
make go/mocks
Since the HCP CLI is shared amongst multiple products, care must be taken to not pollute the top level command with potentially conflicting commands. To avoid this, each product is placed in its own subcommand:
hcp packer
hcp waypoint
hcp vault-secrets
hcp vault-radar
Subcommands can have further nested subcommands themselves. When a service has an object that has CRUD operations on it, the object should likely be a subcommand with further nested commands:
$ hcp vault-secrets apps [create, list, delete, read, update]
This structure naturally has a layout of chaining nouns together with the last
command being a verb such as create, delete, list, etc. Other than the top-level
noun, nouns should be pluralized. As an example prefer hcp iam groups
to hcp iam group
.
To help users become familiar with our CLI, we should be consistent with common verbs across commands. Prefer using:
- create: creating a new resource
- list: listing resources
- delete: deleting a resource
- read: reading an existing resource in more detail
- update: updating an existing resource
If a command requires that a project or organization ID is set, the following
PersistentPreRun
function should be used:
// Require only the organization to be set
cmd := &cmd.Command{
...
PersistentPreRun: func(c *cmd.Command, args []string) error {
return cmd.RequireOrganization(ctx)
},
}
// Require the organization and project to be set
cmd := &cmd.Command{
...
PersistentPreRun: func(c *cmd.Command, args []string) error {
return cmd.RequireOrgAndProject(ctx)
},
}
Short help must be a single sentence that describes the command. It must start with a capital letter and end with a period. It should be a concise description of what the command does.
Long help should be a more detailed description of the command. If certain commands are expected to be used together, this is a good place to mention that. Long help should be written in full sentences and should be grammatically correct, and it should end with a period.
All long help should use the heredoc
package to format the help text. The
package allows formatting the help text (making text bold, colored, italic, etc)
and allows for outputting text appropriate for the command line and generated
markdown documentation. For more detail see the heredoc
documentation.
For consistency, commands share a common first sentence. For command groups it
is:
```sh
The {{ template "mdCodeOrBold" "hcp iam groups" }} command group ...
And for commands it is:
The {{ template "mdCodeOrBold" "hcp iam groups create" }} command ...
Replace hcp iam groups
and hcp iam groups create
with the appropriate command.
Commands can include examples to help users understand how to use the command. Commands should err towards having examples, even if they appear simple.
Example preambles should be full sentences that start with a capital letter, and end in a colon.
The example themselves should start with either a #
for a comment or a $
for
the command. A comment should be reserved for rare cases where additional detail
needs to be provided that doesn't fit into the preamble.
$ hcp iam groups create example
When the example contains flags, display the flag as --flag-name=value
instead
of --flag-name value
. This ensures that examples are consistent across
commands and leaves no room for ambiguity as to whether the flag takes a value
or not (the value can not be mistaken for a positional argument).
To handle long commands that don't fit on a single line, use a backslash \
to
split the command into multiple lines. The subsequent lines should be indented
by two spaces.
$ hcp iam groups create example \
--description="This is an example group."
Positional arguments should generally be used to specify the resource being acted upon or created. Flags should be used for all other options.
It should be very rare to have a command that requires more than one positional argument.
When defining a flag, ensure you mark whether the flag is required or not. The CLI framework will validate that all required flags are set before running the command.
Setting the flag's DisplayValue is highly recommend as well. The DisplayValue is used when displaying flag usage. As an example, a flag defined as follows:
{
Name: "member",
DisplayValue: "ID",
Description: "The ID of the principal to add to the group.",
Value: flagvalue.Simple("", &opts.Member),
},
Will have help output that looks like:
FLAGS
--member=ID
The ID of the user principal to add to the group.
The DisplayValue provides additional context to help the user understand what the expected value should be.
Commands should not directly use os.Stdout
, os.Stderr
, or os.Stdin
.
Instead, use the following which are passed to your command either via the
cmd.Command
or the cmd.Context
.
ctx.IO
: ProvidesIn()
,Out()
, andErr()
, as well as helpers for prompting and reading secrets.ctx.Output
: Use this for structured output. The outputter will automatically be configured by the--format
flag and can output JSON, table format, or pretty printed key-value pairs.cmd.Logger()
: Use this for accessing a logger for your command.
Intuitively one may assume to output errors to ctx.IO.Err()
and everything
else to ctx.IO.Out()
. However, this is not the case. Out and Err map the CLIs
stdout and stderr file descriptors. To allow chaining of commands, it is
important the stdout is reserved for the primary output of the command and all
other help output should be sent to stderr.
- Out: The primary output for your command should go to stdout. Anything that is machine readable should also go to stdout—this is where piping sends things by default.
- Err: Log messages, errors, and so on should all be sent to stderr. This means that when commands are piped together, these messages are displayed to the user and not fed into the next command.
When the CLI is invoked with --quiet
, all Err output is suppressed.
As an example, if your command deletes a resource and you want to display a success message to the user, you should output that to Err as it is not machine readable and is intended only for humans. By writing to Err, we also make it easier for users to write scripts that suppress these non critical messages.
fmt.Fprintf(opts.IO.Err(), "Deleted application %q\n", appID)
To emit logs from your command, capture the logger from the command passed into the RunF function. This logger is automatically configured to be prefixed with the command name and is set to the appropriate log level based on the invocation.
RunF: func(c *cmd.Command, args []string) error {
opts.Logger = c.Logger()
...
},
To display logs, run the command with --debug
for debug logging or --debug --debug
for trace logging.
To output the results of a command in a structured format use the passed
ctx.Output
object. This object supports outputting objects in multiple formats
(JSON, Table, Pretty) and is automatically configured by the --format
flag or
a profile configuration.
There are multiple ways to emit output, each providing increasing flexibility.
// Assuming we are outputting the following resource
type Project struct {
ID string
Name string
Metadata *ProjectMetadata
CreatedAt time.Time
}
type ProjectMetadata struct {
Owner string
Description string
}
- The simplest way is to use the
Show
method:
The Show
method uses reflection to determine the fields in an object to display.
It supports being passed a single object or a slice of objects. The fields will
be displayed alpahbetically and any CamelCase fields will become space
deliminated (e.g. MyField
becomes My Field
) when outputted..
The second parameter to Show is the default format to display the output with.
func getProject(opts *GetOptions) error {
resp, err := ... // Get the resource
respPayload := resp.GetPayload().Project
return opts.Output.Show(respPayload, format.Pretty)
}
This will output the following:
ID: 1234
Name: My Project
Metadata Owner: 21328901902
Metadata Description: My Project Description
Created At: 2021-01-01T00:00:00Z
- Using
NewDisplayer
:
The outputter has a method called Display
that takes a Displayer
interface.
NewDisplayer
is a helper function that creates a Displayer
from a struct.
NewDisplayer gives you control over the names of fields, the order they are
displayed in, and the formatting of the output.
func getProject(opts *GetOptions) error {
resp, err := ... // Get the resource
respPayload := resp.GetPayload().Project
// Create the fields. The fields allow setting the outputting name directly
// and the value is a text/template which allows additional formatting.
projectFields = []format.Field{
format.NewField("Name", "{{ .Name }}"),
format.NewField("Project ID", "{{ .ID }}"), // Display Project ID instead of ID
format.NewField("Owner", "{{ .Metadata.Owner }}"), // Access sub-structs
format.NewField("Description", "{{ .Metadata.Description }}"),
format.NewField("Created At", "{{ .CreatedAt }}"),
}
// Display the created project
d := format.NewDisplayer(resp.Payload.Project, format.Pretty, projectFields)
return opts.Output.Display(d)
}
This will output the following:
Project ID: 1234
Name: My Project
Owner: 21328901902
Description: My Project Description
Created At: 2021-01-01T00:00:00Z
- Directly implementing the
Displayer
interface:
For ultimate flexibility, you can implement the Displayer
interface directly.
There are two main use cases for this. The first is if you want to customize how
a field is displayed you can wrap the object that is being displayed in a custom
type and implement a function on the wrapped type. Then you can use the
text/template to invoke that function.
type wrappedProject struct {
*Project
}
func (p *wrappedProject) Owner() (string, error) {
resp, err := iam.GetUser(p.Metadata.Owner)
if err != nil "
return "", err
}
return resp.User.Name, nil
}
func (d *ProjectDisplayer) FieldTemplates() []Field {
return []Field{
NewField("Name", "{{ .Name }}"),
NewField("Project ID", "{{ .ID }}"),
NewField("Owner", "{{ .Owner }}"),
NewField("Description", "{{ .Metadata.Description }}"),
NewField("Created At", "{{ .CreatedAt }}"),
}
}
...
The second use case is if you want to output a different object for JSON than
the table/pretty format. This can be accomplished by implementing the
TemplatePayload
interface. To see an example of this, see how IAM policies are
displayed.
If a command results in an error, return the error directly. The CLI framework will handle displaying the error message to the user.
func good() error {
if err := doSomething(); err != nil {
return fmt.Error("failed to do the thing: %w", err)
}
...
}
// This will cause the error to be displayed twice.
func bad() error {
if err := doSomething(); err != nil {
fmt.Fprintf(opts.IO.Err(), "failed to do the thing: %v\n", err)
return fmt.Error("failed to do the thing: %w", err)
}
...
}
When the command is a destructive action, prompt the user for confirmation. This can be done as follows:
func deleteGroup(opts *DeleteOptions) error {
if opts.IO.CanPrompt() {
ok, err := opts.IO.PromptConfirm("The group will be deleted.\n\nDo you want to continue")
if err != nil {
return fmt.Errorf("failed to retrieve confirmation: %w", err)
}
if !ok {
return nil
}
}
// Do delete
...
}
If the user invokes the command with --quiet
, the prompt will be suppressed.
Commands should not accept secrets from environment variables, flags, or
positional arguments. This will leak the secret to ps
output and potentially
shell history.
Instead prefer reading secrets from a file or from stdin, interactively or
non-interactively. As an example have a flag called --secret-file
:
--secret-file=/path/to/secret/file
: Read the secret from the passed file.--secret-file=-
: Read the secret from stdin.
cat /path/to/secret/file | hcp secrets create --secret-file=-
- If
--secret-file
is not passed, prompt the user for the secret.
func getSecret(io iostreams.IOStreams) (string, error) {
fmt.Fprintln(io.Err(), "Enter the plaintext secret to upload:")
data, err := io.ReadSecret()
...
}
The hcp
CLI generates the command documentation found on
developer.hashicorp.com.
To check that the generated documentation for your command is correct before
committing the code, generate the documentation and run the
developer.hashicorp.com documentation locally.
First checkout the hcp-docs
repository. The following commands
assume the hcp-docs
repository is checked out in the same parent directory as
hcp
.
$ make docs/gen
$ make docs/move
$ cd ../hcp-docs
$ make website
Releasing is currently a manual process, please reach out to #team-cloud-core-platform for assistance.
If it is your first time releasing, following the onboarding steps here.
To get the main branch ready for a release ensure the following:
- The version in
version/VERSION
is updated to the desired version. - The
CHANGELOG.md
is updated with the new version and the changes. The changelog can be generated usingLAST_RELEASE_GIT_TAG=v0.x.y make changelog/build
- Changes since the last release are manually tested and working.
Then follow the release steps outlined here.
After a successful release:
- Update the
version/VERSION
file to the next version with-dev
appended. - Update the developer.hashicorp.com documentation by following the steps
outlined in the Validating/generating the developer.hashicorp.com documentation section.
PR the changes to the
hcp-docs
repository, have a team member review them, and merge.