Our agent CLI will provide just enough features to use as a Docker image’s entry point and run our service, parse flags, and then configure and run the agent.
I use the Cobra[57] library to handle commands and flags because it works well for creating both simple CLIs and complex applications. It’s used in the Go community by projects such as Kubernetes, Docker, Helm, Etcd, Hugo, and more. And Cobra integrates with a library called Viper,[58] which is a complete configuration solution for Go applications.
The first step is to create a cmd/proglog/main.go file, beginning with this code:
| package main |
| |
| import ( |
| "log" |
| "os" |
| "os/signal" |
| "path" |
| "syscall" |
| |
| "github.com/spf13/cobra" |
| "github.com/spf13/viper" |
| "github.com/travisjeffery/proglog/internal/agent" |
| "github.com/travisjeffery/proglog/internal/config" |
| ) |
| |
| func main() { |
| cli := &cli{} |
| |
» | cmd := &cobra.Command{ |
» | Use: "proglog", |
» | PreRunE: cli.setupConfig, |
» | RunE: cli.run, |
» | } |
| |
| if err := setupFlags(cmd); err != nil { |
| log.Fatal(err) |
| } |
| |
| if err := cmd.Execute(); err != nil { |
| log.Fatal(err) |
| } |
| } |
The highlighted code defines our sole command. Our CLI is about as simple as it gets. In more complex applications, this command would act as the root command tying together your subcommands. Cobra calls the RunE function you set on your command when the command runs. Put or call the command’s primary logic in that function. Cobra enables you to run hook functions to run before and after RunE.
Cobra provides persistent flags and hooks for applications with many subcommands (so we’re not using them in our program)—persistent flags and hooks apply to the current command and all its children. A common use case for a persistent flag is in API-wrapping CLIs. In these CLIs, every subcommand will need a flag for the API’s endpoint address. In this situation, you’d use an --api-addr persistent flag that you declare once on the root command for all the subcommands to inherit.
To define our cli and cfg types, add the following code:
| type cli struct { |
| cfg cfg |
| } |
| |
| type cfg struct { |
| agent.Config |
| ServerTLSConfig config.TLSConfig |
| PeerTLSConfig config.TLSConfig |
| } |
I typically create a cli struct in which I can put logic and data that’s common to all the commands. I created a separate cfg struct from the agent.Config struct to handle the field types that we can’t parse without error handling: the *net.TCPAddr and the *tls.Config.
Now, let’s set up our CLI’s flags.
Below the previous snippet, add this code to declare our CLI’s flags:
| func setupFlags(cmd *cobra.Command) error { |
| hostname, err := os.Hostname() |
| if err != nil { |
| log.Fatal(err) |
| } |
| |
| cmd.Flags().String("config-file", "", "Path to config file.") |
| |
| dataDir := path.Join(os.TempDir(), "proglog") |
| cmd.Flags().String("data-dir", |
| dataDir, |
| "Directory to store log and Raft data.") |
| cmd.Flags().String("node-name", hostname, "Unique server ID.") |
| |
| cmd.Flags().String("bind-addr", |
| "127.0.0.1:8401", |
| "Address to bind Serf on.") |
| cmd.Flags().Int("rpc-port", |
| 8400, |
| "Port for RPC clients (and Raft) connections.") |
| cmd.Flags().StringSlice("start-join-addrs", |
| nil, |
| "Serf addresses to join.") |
| cmd.Flags().Bool("bootstrap", false, "Bootstrap the cluster.") |
| |
| cmd.Flags().String("acl-model-file", "", "Path to ACL model.") |
| cmd.Flags().String("acl-policy-file", "", "Path to ACL policy.") |
| |
| cmd.Flags().String("server-tls-cert-file", "", "Path to server tls cert.") |
| cmd.Flags().String("server-tls-key-file", "", "Path to server tls key.") |
| cmd.Flags().String("server-tls-ca-file", |
| "", |
| "Path to server certificate authority.") |
| |
| cmd.Flags().String("peer-tls-cert-file", "", "Path to peer tls cert.") |
| cmd.Flags().String("peer-tls-key-file", "", "Path to peer tls key.") |
| cmd.Flags().String("peer-tls-ca-file", |
| "", |
| "Path to peer certificate authority.") |
| |
| return viper.BindPFlags(cmd.Flags()) |
| } |
These flags allow people calling your CLI to configure the agent and learn the default configuration.
With the pflag.FlagSet.{{type}}Var methods, we can set our configuration’s values directly. However, the problem with setting the configurations directly is that not all types have supporting APIs out of the box. Our BindAddr configuration is an example, which is a *net.TCPAddr that we need to parse from a string. You can define custom flag values[59] when you have enough flags of the same type, or just use an intermediate value otherwise.
But what if we want to configure our service with more than flags, such as with a file? We’ll look at how to read in the configuration from a file, too, for dynamic configurations.
Viper provides a centralized config registry system where multiple configuration sources can set the configuration but you can read the result in one place. You could allow users to set the configuration with flags, a file, or by loading dynamic configs from a service like Consul—Viper supports all of these.
With a configuration file, you can support dynamic config changes to a running service. The service watches the config file for changes and updates accordingly. For example, you may run your service at INFO-level logs by default but need DEBUG-level logs when you’re debugging an issue with the running service. A configuration file also enables other processes to set up the configuration for the service. We’ll see an example of that with our service where we have an init container that sets up the configuration for the service’s container.
I’ve given usable defaults for the configurations we have to set: the data directory, bind address, the RPC port, and the node name. Try to set usable default flag values instead of requiring users to set them.
After declaring the flags, the next step is to execute the root command to parse the process’s arguments and search through the command tree to find the correct command to run. We just have the one command, so we’re not making Cobra work hard.
Add this snippet to set up the config:
| func (c *cli) setupConfig(cmd *cobra.Command, args []string) error { |
| var err error |
| |
| configFile, err := cmd.Flags().GetString("config-file") |
| if err != nil { |
| return err |
| } |
| viper.SetConfigFile(configFile) |
| |
| if err = viper.ReadInConfig(); err != nil { |
| // it's ok if config file doesn't exist |
| if _, ok := err.(viper.ConfigFileNotFoundError); !ok { |
| return err |
| } |
| } |
| |
| c.cfg.DataDir = viper.GetString("data-dir") |
| c.cfg.NodeName = viper.GetString("node-name") |
| c.cfg.BindAddr = viper.GetString("bind-addr") |
| c.cfg.RPCPort = viper.GetInt("rpc-port") |
| c.cfg.StartJoinAddrs = viper.GetStringSlice("start-join-addrs") |
| c.cfg.Bootstrap = viper.GetBool("bootstrap") |
| c.cfg.ACLModelFile = viper.GetString("acl-mode-file") |
| c.cfg.ACLPolicyFile = viper.GetString("acl-policy-file") |
| c.cfg.ServerTLSConfig.CertFile = viper.GetString("server-tls-cert-file") |
| c.cfg.ServerTLSConfig.KeyFile = viper.GetString("server-tls-key-file") |
| c.cfg.ServerTLSConfig.CAFile = viper.GetString("server-tls-ca-file") |
| c.cfg.PeerTLSConfig.CertFile = viper.GetString("peer-tls-cert-file") |
| c.cfg.PeerTLSConfig.KeyFile = viper.GetString("peer-tls-key-file") |
| c.cfg.PeerTLSConfig.CAFile = viper.GetString("peer-tls-ca-file") |
| |
| if c.cfg.ServerTLSConfig.CertFile != "" && |
| c.cfg.ServerTLSConfig.KeyFile != "" { |
| c.cfg.ServerTLSConfig.Server = true |
| c.cfg.Config.ServerTLSConfig, err = config.SetupTLSConfig( |
| c.cfg.ServerTLSConfig, |
| ) |
| if err != nil { |
| return err |
| } |
| } |
| |
| if c.cfg.PeerTLSConfig.CertFile != "" && |
| c.cfg.PeerTLSConfig.KeyFile != "" { |
| c.cfg.Config.PeerTLSConfig, err = config.SetupTLSConfig( |
| c.cfg.PeerTLSConfig, |
| ) |
| if err != nil { |
| return err |
| } |
| } |
| |
| return nil |
| } |
setupConfig(cmd *cobra.Command, args []string) reads the configuration and prepares the agent’s configuration. Cobra calls setupConfig before running the command’s RunE function.
Finish writing the program by including this run method:
| func (c *cli) run(cmd *cobra.Command, args []string) error { |
| var err error |
| agent, err := agent.New(c.cfg.Config) |
| if err != nil { |
| return err |
| } |
| sigc := make(chan os.Signal, 1) |
| signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) |
| <-sigc |
| return agent.Shutdown() |
| } |
run(cmd *cobra.Command, args []string) runs our executable’s logic by:
Okay, we have our executable that we can use as our Docker image’s entry point, so let’s write our Dockerfile and build the image.