From Flags to Subcommands: Rewriting a Go CLI With Cobra

Introduction

In the previous article, I shared my experience creating aws-doctor, an open-source tool for optimizing AWS costs from the terminal. In this article, I’ll talk a bit about aws-doctor version 2 and the related breaking changes that led me to release a new major version. Right off the bat, I’ll tell you that we’ll be talking quite a bit about Cobra, the Golang framework for creating CLIs.

Context

In version v1.10.2 of aws-doctor, the tool was quite stable and met its goal. It had three main features, which were:

  • default flow: to compare the current month’s expenses with the previous month in the same time period.
  • --trend flag: to show the cost trend of the last 6 months in the console.
  • --waste flag: to detect zombie resources in AWS.

Besides this, there were other global flags applicable to any command:

  • --profile to specify the AWS CLI profile to use.
  • --region to specify the AWS region to use.
  • --output to specify the output format (json, table).

Motivation

One of the features available in version v1.10.2 was the ability to specify the checks to execute for the --waste flow. This way, if the user only wanted to run the check on ec2 and s3 resources, they could run the following command:

aws-doctor --waste ec2,s3

And this worked perfectly, but from that moment on I realized that something was not right with the tool, and now I’ll show you why. Up to that point, the project wasn’t using any framework to create the CLI, it only had a service called FlagService that I show you below:

package flag

import "github.com/elC0mpa/aws-doctor/model"

type service struct{}

// Service is the interface for CLI flag service.
type Service interface {
	GetParsedFlags(args []string) (model.Flags, error)
}
// Package flag provides a service for parsing CLI flags.
package flag

import (
	"flag"
	"strings"

	"github.com/elC0mpa/aws-doctor/model"
)

// NewService creates a new Flag service.
func NewService() Service {
	return &service{}
}

func (s *service) GetParsedFlags(args []string) (model.Flags, error) {
	fs := flag.NewFlagSet("aws-doctor", flag.ContinueOnError)

	region := fs.String("region", "", "AWS region (defaults to AWS_REGION, AWS_DEFAULT_REGION, or ~/.aws/config)")
	profile := fs.String("profile", "", "AWS profile configuration")
	trend := fs.Bool("trend", false, "Display a trend report for the last 6 months")
	waste := fs.Bool("waste", false, "Display AWS waste report (e.g., --waste ec2,s3)")
	output := fs.String("output", "table", "Output format: table or json")
	version := fs.Bool("version", false, "Display version information")
	update := fs.Bool("update", false, "Update aws-doctor to the latest version")

	var wasteChecks []string

	filteredArgs := make([]string, 0, len(args))

	for i := 0; i < len(args); i++ {
		arg := args[i]
		filteredArgs = append(filteredArgs, arg)

		// If the current argument is the waste flag and the next argument is not a flag,
		// we treat the next argument as the list of specific checks to run.
		if (arg == "--waste" || arg == "-waste") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
			wasteChecks = strings.Split(args[i+1], ",")
			i++ // Consume the next argument so it's not parsed as a positional argument
		}
	}

	if err := fs.Parse(filteredArgs); err != nil {
		return model.Flags{}, err
	}

	return model.Flags{
		Region:      *region,
		Profile:     *profile,
		Trend:       *trend,
		Waste:       *waste,
		WasteChecks: wasteChecks,
		Output:      *output,
		Version:     *version,
		Update:      *update,
	}, nil
}

As you can see, this service was in charge of parsing the application’s flags and then returning them in a struct. But the problem started from the weird logic you can see written in the for loop, which basically took care of detecting if the user, after passing the --waste flag, had passed an additional argument with the checks to execute, and if so, parse that argument and save it in the flags struct. Although this worked, my instinct told me that if I continued down this path, the tool would become increasingly difficult to maintain, and for this reason I started looking for alternatives.

The Solution: Cobra

After doing a little research, I came to the conclusion that the way to solve this problem and keep the tool stable was by using the Cobra framework, which among other things, is used by very famous CLIs, such as GitHub and GitLab.

Paradigm Shift

Previously, as you read, the tool used flags for everything, even for executing specific features, like the case of --waste and --trend. After studying a bit about Cobra, I learned that these shouldn’t be flags, but subcommands, and that flags should be used to modify the behavior of the commands. For example, instead of running aws-doctor --waste, now the command to run the waste feature would be aws-doctor waste, and since each subcommand has its own arguments, in the case of this subcommand, the arguments would be the checks to execute, so the full command to execute the ec2 and s3 checks would be aws-doctor waste ec2 s3.

This is the main reason why I decided to release a new major version, since this paradigm shift broke the way the end user used the tool.

Implementation

Now, the implementation is much more structured and scalable. It’s recommended to have a cmd folder at the root of the project, where there is a file for each subcommand, and a root.go file that is in charge of defining the root command and global flags. In each of the subcommand files, the specific command and its arguments are defined, and then the corresponding logic is implemented in the Run function.

This is the code in the cmd/root.go file:

package cmd

import (
	"context"
	"fmt"

	"github.com/elC0mpa/aws-doctor/model"
	awsconfig "github.com/elC0mpa/aws-doctor/service/aws_config"
	"github.com/elC0mpa/aws-doctor/service/cloudwatchlogs"
	"github.com/elC0mpa/aws-doctor/service/cloudwatchmetrics"
	awscostexplorer "github.com/elC0mpa/aws-doctor/service/costexplorer"
	awsec2 "github.com/elC0mpa/aws-doctor/service/ec2"
	"github.com/elC0mpa/aws-doctor/service/elb"
	"github.com/elC0mpa/aws-doctor/service/orchestrator"
	"github.com/elC0mpa/aws-doctor/service/output"
	"github.com/elC0mpa/aws-doctor/service/rds"
	"github.com/elC0mpa/aws-doctor/service/s3"
	awssts "github.com/elC0mpa/aws-doctor/service/sts"
	"github.com/elC0mpa/aws-doctor/service/update"
	"github.com/elC0mpa/aws-doctor/utils/banner"
	"github.com/elC0mpa/aws-doctor/utils/spinner"
	"github.com/spf13/cobra"
)

var (
	region              string
	profile             string
	outputFormat        string
	versionInfo         model.VersionInfo
	orchestratorBuilder = buildOrchestrator
)

func buildOrchestrator(needsAWS bool) (orchestrator.Service, error) {
	outputService := output.NewService(outputFormat)
	updateService := update.NewService()

	if !needsAWS {
		return orchestrator.NewService(nil, nil, nil, nil, nil, nil, nil, outputService, updateService, versionInfo), nil
	}

	banner.DrawBannerTitle()

	cfgService := awsconfig.NewService()

	awsCfg, err := cfgService.GetAWSCfg(context.Background(), region, profile)
	if err != nil {
		return nil, fmt.Errorf("failed to load AWS config: %w", err)
	}

	spinner.StartSpinner()

	costService := awscostexplorer.NewService(awsCfg)
	stsService := awssts.NewService(awsCfg)
	ec2Service := awsec2.NewService(awsCfg)
	elbService := elb.NewService(awsCfg)
	s3Service := s3.NewService(awsCfg)
	cloudwatchlogsService := cloudwatchlogs.NewService(awsCfg)
	cwMetricsService := cloudwatchmetrics.NewService(awsCfg)
	rdsService := rds.NewService(awsCfg, cwMetricsService)

	return orchestrator.NewService(stsService, costService, ec2Service, elbService, s3Service, cloudwatchlogsService, rdsService, outputService, updateService, versionInfo), nil
}

var rootCmd = &cobra.Command{
	Use:   "aws-doctor",
	Short: "A comprehensive health check for your AWS accounts",
}

// Execute adds all child commands to the root command and sets flags appropriately.
func Execute(version, commit, date string) error {
	versionInfo = model.VersionInfo{
		Version: version,
		Commit:  commit,
		Date:    date,
	}

	return rootCmd.Execute()
}

func init() {
	rootCmd.PersistentFlags().StringVar(&region, "region", "", "AWS region (defaults to AWS_REGION, AWS_DEFAULT_REGION, or ~/.aws/config)")
	rootCmd.PersistentFlags().StringVar(&profile, "profile", "", "AWS profile configuration")
	rootCmd.PersistentFlags().StringVar(&outputFormat, "output", "table", "Output format: table, json or csv")
}

In this file, all the necessary initializations for the tool are made. For this, the buildOrchestrator method is used, which is in charge of initializing all the necessary services to execute the tool’s features. This method is used from each of the other subcommands, and is passed a boolean indicating whether the subcommand needs access to AWS or not. This way, if the subcommand doesn’t need access to AWS, the related services are not initialized, which makes the tool faster and more efficient.

Next, I show you the cmd/waste.go file, which is in charge of executing the waste feature:

package cmd

import (
	"strings"

	"github.com/elC0mpa/aws-doctor/model"
	"github.com/spf13/cobra"
)

var wasteCmd = &cobra.Command{
	Use:   "waste [checks...]",
	Short: "Display AWS waste report (e.g., ec2 s3)",
	RunE: func(cmd *cobra.Command, args []string) error {
		orch, err := orchestratorBuilder(true)
		if err != nil {
			return err
		}

		var parsedChecks []string

		for _, arg := range args {
			parsedChecks = append(parsedChecks, strings.Split(arg, ",")...)
		}

		flags := model.Flags{
			Region:      region,
			Profile:     profile,
			Output:      outputFormat,
			Waste:       true,
			WasteChecks: parsedChecks,
		}

		return orch.Orchestrate(flags)
	},
}

func init() {
	rootCmd.AddCommand(wasteCmd)
}

As you can see, basically the orchestrator is built with access to AWS, the arguments are parsed to get the checks to execute, and then the orchestrator’s Orchestrate method is called, passing the corresponding flags.

It is exactly in this example where you can clearly see the advantage of using Cobra, since the implementation is much cleaner and easier to understand, besides following the recommended paradigm for CLIs, which makes the tool more intuitive for users.

Conclusions

Making the decision to migrate to Cobra was a great step to improve the scalability of this CLI as well as its readability, and although it implied making a paradigm shift and releasing a new major version, I believe it was the right decision in the long run. Now the tool is much better structured, it is easier to maintain and to extend with new features in the future. Also, by following the recommended paradigm for CLIs, I believe the end user experience has also improved significantly.


Related Content

Get latest posts delivered right to your inbox
0%