Building a go cli tool to generate Kubernetes deployment files
The Go cobra framework is a great tool that can be used to write command-line interfaces in golang. It is used by many different organisations and projects because of how easy and simple it is to use.
Link to the Cobra framework: https://cobra.dev/. Cobra framework documentation: https://pkg.go.dev/github.com/spf13/cobra
Using it is simple. You just need to run
go get -u github.com/spf13/cobra/cobra
in your terminal. This'll install the cobra library and all the associated dependencies. The -u
flag will make sure that the dependencies are taken care of.
In your go code, you'll need to import the library by writing:
import "github.com/spf13/cobra"
Throughout this blog, I'll be walking you through the basics of working with the cobra framework to build a CLI tool that generates deployment files based on the image name, docker image and other options.
Context regarding the Kubernetes deployment file and its various fields is beneficial to have.
Setting up the basics
This here is the GitHub repo link if you intend to follow along: https://github.com/abs007/kubewrite
The go cobra framework comes with the library itself and also the cobra generator. The generator can be used for setting up all the required basic files. Setting up your project this way also helps ensure a proper structure. We'll be using the generator for this blog. Link for the generator: https://github.com/spf13/cobra#usage
Let's first download the generator:
go install github.com/spf13/cobra-cli@latest
Now run the command: cobra-cli
to see all the flags and sub-commands that you can use.
If it shows that the command can't be found, then you should consider adding the location to your GOBIN
location (~/go/bin
) to the $PATH
variable. More info here: https://phoenixnap.com/kb/linux-add-to-path
Let's set up the directory and the project structure now
mkdir kubewrite
cd kubewrite
go mod init kubewrite
cobra-cli init kubewrite
What go mod init
is initialize/create a new module file for keeping track of dependencies and cobra-cli init kubewrite
initializes a new cobra-cli project structure.
You'll be getting a structure like this:
The cobra-cli
command creates a separate folder (the second "kubewrite" folder in this case) which I think is unintuitive. Let's move the contents of the inner kubewrite
up the hierarchy to get this:
You'll notice that the main.go
file has some boilerplate. Let's keep it that way. This file will be used as an entry point. We'll instead be writing the main functionality in the root.go
, inside the cmd
folder.
Working with root.go
Before we start, let's just define the proper import statements. You'll understand the purpose of each imported package as I go through the explanation.
import (
"os"
"text/template"
"github.com/spf13/cobra"
)
Inside the root.go
file, you'd be finding a variable, function and method. The variable is rootCmd
. What this does is create a pointer to the cobra.Command
struct. The struct has different fields for the user to fill up according to their needs. Hover over each to read the comments and understand that particular field. You'll understand that Use
is to show how the user would be using the command and Short
and Long
are for providing descriptions. Let's remove the Long
field since Short
will be enough for our use case. Let's remove the rootCmd
variable for now. We'll add it later to our own function.
This root command (rootCmd
) is the first command in a CLI structure to which we add further sub-commands. The way to do that via the generator would be to use the cobra-cli add <command_name>
command.
Next, in the root file, you'll find the init()
function. This function exists for adding flags to our command. Let's remove that. I'll explain why later.
Now, let's declare a deployment constant. This'll exist for us as a base deployment template whose fields we'll be changing based on the user inputs. For the moment, let's support four fields: Name
, Replicas
, Image
and Port
.
const deploymentTemplate string = `apiVersion: apps/v1
kind: Deployment
metadata:
name: {{.Name}}
spec:
replicas: {{.Replicas}}
selector:
matchLabels:
app: {{.Name}}
template:
metadata:
labels:
app: {{.Name}}
spec:
containers:
- name: {{.Name}}
image: {{.Image}}
ports:
- containerPort: {{.Port}}
`
Let's also define a Deployment struct so that we have all those four fields defined at a central location:
type Deployment struct {
Name string
Replicas int32
Image string
Port int32
}
Let's create a new function: kubewrite() *cobra.Command {}
which returns a pointer to the cobra.Command
struct. If you guessed right, yes we'll be defining the rootCmd
variable inside this function and returning and also the flags (for which we removed the init()
func).
func kubewrite() *cobra.Command {
var name, image string
var replicas, port int
rootCmd := &cobra.Command{
Use: "kubewrite [flags]",
Short: "A command-line tool for generating Kubernetes deployment YAML",
RunE: func(cmd *cobra.Command, args []string) error {
deployment := Deployment{
Name: name,
Replicas: replicas,
Image: image,
Port: port,
}
t := template.Must(template.New("deployment").Parse(deploymentTemplate))
err := t.Execute(os.Stdout, deployment)
if err != nil {
return err
}
return nil
},
}
//Lower half of the func is below
This does seem like a lot. Let's walk through it, step-by-step:
The first 2 lines create variables that would be storing the user input
Next, we create the
rootCmd
variable. TheRunE
field (after theShort
) exists to write the logic for the function that would run whenever a user would run the command. TheE
is there to return an error, if anyThen, we create a
deployment
variable of typeDeployment
and pass in the user-defined values into the variableNext, we'll be using the
template.Must()
function from thetemplate
package. The func accepts a template string and returns a template object. We give the name of the template intemplate.New("deployment")
and then parse thedeploymentTemplate
constantThe
t.Execute()
method then writes the output to the terminal and takes in thedeployment
variable as the data to feed into the template objectt
.
The function isn't finished yet. I broke down the lower half of the func to help in readability. Here is the remaining part:
// Lower half
rootCmd.Flags().StringVarP(&name, "name", "n", "", "The name of the deployment")
rootCmd.MarkFlagRequired("name")
rootCmd.Flags().StringVarP(&image, "image", "i", "", "The Docker image to deploy")
rootCmd.MarkFlagRequired("image")
rootCmd.Flags().IntVarP(&replicas, "replicas", "r", 1, "The number of replicas to deploy")
rootCmd.Flags().IntVarP(&port, "port", "p", 80, "The port the container should listen on")
return rootCmd
}
What this part does is add the flags. Flags are additional arguments that you'd pass along while executing the command. Let's look at the first one. We use the
StringVarP()
method to define a flag of typestring
. TheP
exists to allow for a shorthand replacement. In your IDE, if you hover over the method, you'll be able to see the different arguments it accepts and then relate to the ones being passed here. Similarly, we create the other flags.You'll notice the
MarkFlagRequired()
method. This does pretty much what the name suggests. This makes it important for you to provide the value for this flag.Finally, we return the
rootCmd
pointer too.
There's a last function that hasn't been discussed yet. Its the Execute()
func. This function gets called by the main.go
file in the root directory and its purpose is to execute the command and exit (using the os
package) when the job's done. Execution here means running the command so that it can do its job and take in user parameters.
All of this creates the core logic for the kubewrite
command.
Running the command
Let's run and check if the command is working. Before we do that, let's just run this command from the root dir in the terminal to take care of the imports and checksums:
go mod tidy
Next, let's run the actual command via the main.go
file. I'll pass in "nginx:1.14.2" as the image name, "sample" as the name of the deployment and 3 replicas.
go run main.go kubewrite --image="ds" --name="sample" --replicas=3
This should be the output:
If you got this far and understood it all, congrats and I'm glad I could be of help :)
Subscribe to my newsletter
Read articles from Abhisman directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Abhisman
Abhisman
Developer and Freelance writer. Learning and writing about tech.