Building a simple GitOps operator
Now that we have seen how the control loop works, have experimented with declarative commands, and know how to work with basic Git commands, we have enough information to build a basic GitOps operator. We now need three things created, as follows:
- We will initially clone a Git repository and then pull from it to keep it in sync with remote.
- We’ll take what we found in the Git repository and try to apply it.
- We’ll do this in a loop so that we can make changes to the Git repository and they will be applied.
The code is in Go; this is a newer language from Google, and many operations (ops) tools are built with it, such as Docker, Terraform, Kubernetes, and Argo CD.
Note
For real-life controllers and operators, certain frameworks should be used, such as the Operator Framework (https://operatorframework.io), Kubebuilder (https://book.kubebuilder.io), or sample-controller
(https://github.com/kubernetes/sample-controller).
All the code for our implementation can be found at https://github.com/PacktPublishing/ArgoCD-in-Practice/tree/main/ch01/basic-gitops-operator, while the YAML Ain’t Markup Language (YAML) manifests we will be applying are at https://github.com/PacktPublishing/ArgoCD-in-Practice/tree/main/ch01/basic-gitops-operator-config.
The syncRepo
function receives the repository Uniform Resource Locator (URL) to clone and keep in sync, as well as the local path where to do it. It then tries to clone the repository using a function from the go-git
library (https://github.com/go-git/go-git), git.PlainClone
. If it fails with a git.ErrRepositoryAlreadyExists
error, this means we have already cloned the repository and we need to pull it from the remote to get the latest updates. And that’s what we do next: we open the Git repository locally, load the worktree, and then call the Pull
method. This method can give an error if everything is up to date and there is nothing to download from the remote, so for us, this case is normal (this is the condition: if err != nil && err == git.NoErrAlreadyUpToDate
). The code is illustrated in the following snippet:
func syncRepo(repoUrl, localPath string) error { _, err := git.PlainClone(localPath, false, &git.CloneOptions{ URL: repoUrl, Progress: os.Stdout, }) if err == git.ErrRepositoryAlreadyExists { repo, err := git.PlainOpen(localPath) if err != nil { return err } w, err := repo.Worktree() if err != nil { return err } err = w.Pull(&git.PullOptions{ RemoteName: "origin", Progress: os.Stdout, }) if err == git.NoErrAlreadyUpToDate { return nil } return err } return err }
Next, inside the applyManifestsClient
method, we have the part where we apply the content of a folder from the repository we downloaded. Here, we create a simple wrapper over the kubectl apply
command, passing as a parameter the folder where the YAML manifests are from the repository we cloned. Instead of using the kubectl apply
command, we can use the Kubernetes APIs with the PATCH
method (with the application/apply-patch+yaml
content-type header), which means calling apply
on the server side directly. But it complicates the code, as each file from the folder needs to be read and transformed into its corresponding Kubernetes object in order to be able to pass it as a parameter to the API call. The kubectl apply
command does this already, so this was the simplest implementation possible. The code is illustrated in the following snippet:
func applyManifestsClient(localPath string) error { dir, err := os.Getwd() if err != nil { return err } cmd := exec.Command("kubectl", "apply", "-f", path.Join(dir, localPath)) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr err = cmd.Run() return err }
Finally, the main
function is from where we call these functionalities, sync the Git repository, apply manifests to the cluster, and do it in a loop at a 5-second interval (I went with a short interval for demonstration purposes; in live scenarios, Argo CD—for example—does this synchronization every 3 minutes). We define the variables we need, including the Git repository we want to clone, so if you will fork it, please update the gitopsRepo
value. Next, we call the syncRepo
method, check for any errors, and if all is good, we continue by calling applyManifestsClient
. The last rows are how a timer is implemented in Go, using a channel.
Note: Complete code file
For a better overview, we also add the package
and import
declaration; this is the complete implementation that you can copy into the main.go
file.
Here is the code for the main
function where everything is put together:
package main import ( "fmt" "os" "os/exec" "path" "time" "github.com/go-git/go-git/v5" ) func main() { timerSec := 5 * time.Second gitopsRepo := "https://github.com/PacktPublishing/ArgoCD-in-Practice.git" localPath := "tmp/" pathToApply := "ch01/basic-gitops-operator-config" for { fmt.Println("start repo sync") err := syncRepo(gitopsRepo, localPath) if err != nil { fmt.Printf("repo sync error: %s", err) return } fmt.Println("start manifests apply") err = applyManifestsClient(path.Join(localPath, pathToApply)) if err != nil { fmt.Printf("manifests apply error: %s", err) } syncTimer := time.NewTimer(timerSec) fmt.Printf("\n next sync in %s \n", timerSec) <-syncTimer.C } }
To make the preceding code work, go to a folder and run the following command (just replace <your-username>
):
go mod init github.com/<your-username>/basic-gitops-operator
This creates a go.mod
file where we will store the Go modules we need. Then, create a file called main.go
and copy the preceding pieces of code in it, and the three functions syncRepo
, applyManifestsClient
, and main
(also add the package
and import
declarations that come with the main
function). Then, run the following command:
go get .
This will download all the modules (don’t miss the last dot).
And the last step is to actually execute everything we put together with the following command:
go run main.go
Once the application starts running, you will notice a tmp
folder created, and inside it, you will find the manifests to be applied to the cluster. The console output should look something like this:
start repo sync Enumerating objects: 36, done. Counting objects: 100% (36/36), done. Compressing objects: 100% (24/24), done. Total 36 (delta 8), reused 34 (delta 6), pack-reused 0 start manifests apply namespace/nginx created Error from server (NotFound): error when creating "<>/argocd-in-practice/ch01/basic-gitops-operator/tmp/ch01/basic-gitops-operator-config/deployment.yaml": namespaces "nginx" not found manifests apply error: exit status 1 next sync in 30s start repo sync start manifests apply deployment.apps/nginx created namespace/nginx unchanged
You can see the same error since, as we tried applying an entire folder, this is happening now too, but on the operator’s second run, the deployment is created successfully. If you look in your cluster, you should find a namespace called nginx
and, inside it, a deployment also called nginx
. Feel free to fork the repository and make changes to the operator and to the config it is applying.
Note: Apply namespace first
The problem with namespace creation was solved in Argo CD by identifying them and applying namespaces first.
We created a simple GitOps operator, showing the steps of cloning and keeping the Git repository in sync with the remote and taking the contents of the repository and applying them. If there was no change to the manifests, then the kubectl apply
command had nothing to modify in the cluster, and we did all this in a loop that imitates pretty closely the control loop we introduced earlier in the chapter. As a principle, this is alsowhat happens in the Argo CD implementation, but at a much higher scale and performance and with a lot of features added.