Abstract to Go: Lets create our own Ansible (Part 1)
Tags: ansible, go, automation, programming
Published: 2023-10-30 22:16:37

If you like automation as much as I do, you must have spent hours automating tasks that probably take 5 minutes to do manually. Things get interesting when it comes to infrastructure automation. Tools like Ansible are used to make changes to several servers at the same time without logging into them. In this article, we will look into how Ansible usually works and then convert that abstract information into code.

## What is Ansible 
Ansible is an agent-less automation tool that can perform a wide range of tasks, such as deploying code, updating systems, and provisioning infrastructure. What's remarkable is that Ansible is agent-less, which means you don't have to install any additional software on your servers to make Ansible work. Behind all that abstraction, it uses SSH to execute commands.

It's also important to know that most workflows using Ansible are designed to be idempotent. That means if you run the same Ansible script multiple times, such as one responsible for installing specific packages, those installations will typically occur just once.

For the sake of this article, we will focus on two significant components of Ansible:

### Inventory File
The inventory file is where all the information about the servers is stored. You can also group servers based on your needs. For example, you might want to run updates on all the backend servers while leaving the database servers as they are. Here's an example of how an inventory file may look:
```
all:  
  hosts:
    server1:  
      ansible_host: sv1.server.com  
      ansible_user: root  
      ansible_ssh_pass: Passw0rd  
      
    server2:  
      ansible_host: sv2.server.com  
      ansible_user: root  
      ansible_ssh_pass: Passw0rd  
   
    server3:  
      ansible_host: sv3.server.com  
      ansible_user: root  
      ansible_ssh_pass: Passw0rd  
      
    server4:  
      ansible_host: sv4.server.com  
      ansible_user: root  
      ansible_ssh_pass: Passw0rd  
    
```
The inventory file, by default, is an INI file format, but Ansible can also accept a YAML file as input.

### Playbook
This contains execution information like :
* What tasks to run 
* Where to run the tasks 
* How to run the tasks (Strategy)
* Maximum number of hosts that are to be run at a time

Here's an example of how a playbook file may look:

```
---
- name: example playbook
  hosts: server1,server2,server3,server4
  strategy: free
  tasks:
    - name: Create a group
      group:
        name: yourgroup
        state: present  
      skip_errors: true
      
    - name: Create a user
      user:
        name: yourusername
        password: yourpassword  
        groups: yourgroup    
        state: present       

```

The playbook suggests that two tasks should be run on servers 1 to 4, using a free strategy. By default, Ansible uses a linear strategy, meaning all servers run the first task, then the second, and so on. A free strategy allows all servers to run tasks concurrently, and information about the execution is collected at the end.


## Design 

So these are the main things that we would need to do make our ansible-like application work 
* Parse the inventory and the playbook files
* Run ssh commands on multiple servers at the same time 
* Implement different strategies on how to run the tasks from playbook 
* Ignore errors from commands if explicitly mentioned in the playbook 



### SSH-Client
So in the end, all the tasks in the playbook should be converted into commands that we run on the server's shell. We would like to capture both the error and the output.

It is also important for us to know the operating system, because there is a possibility that among the set of hosts, there are a few servers that cannot run the command because they have a different operating system. So instead of trying to run these commands on the server, we should just skip the execution altogether.
We also do not want to reconnect to the server for each task.
This calls for a data structure that holds the login details of the SSH client.
This is what the structure may look like 
```
type sshConn struct {
	host   string
	os     string
	user   string
	pw     string
	pkey   string
	client *ssh.Client
	port   int

}

```

It will have an execution method that will run a command, capture its output into a structure, and return it. This is what it may look like 
```
func (sc *sshConn) execute(cmd string) ExecOutput {
	ll := make([]byte, 0)
	mm := make([]byte, 0)
	sshOut := bytes.NewBuffer(ll)
	sshErr := bytes.NewBuffer(mm)

	session, err := sc.client.NewSession()
	if err != nil {
		log.Fatal(err)
	}

	defer session.Close()

	session.Stdout = sshOut
	session.Stderr = sshErr

	session.Run(cmd)
	co := ExecOutput{
		Out: sshOut.String(),
		Err: sshErr.String(),
		Cmd: cmd,
	}

	return co
}

```


### Tasks

Tasks in Ansible playbooks can have various structures. To handle this variability, it's efficient to use a map-based approach for task processing. This method involves parsing tasks as maps and then iterating through the keys to determine the type of task and how to handle it. 

This is what it may look like 

```
func parseTask(task map[string]interface{}) (*Task, error) {
	defer func() {
		if err := recover(); err != nil {
			fmt.Println(err)
		}
	}()
	// TODO: Work the other task level variables that may be present
	var result = &Task{}
	result.os = "any"
	for key, _ := range task {
		switch key {
		// case "copy":
		// 	res = modules.NewCopy(task[key].(map[string]interface{}))
		case LineinfileMod:
			cmds, err := modules.NewLineInFile(task[key].(map[string]interface{}))
			if err != nil {
				return result, err
			}
			result.cmds = cmds

		case fileMod:
			cmds, err := modules.NewFilePermissions(task[key].(map[string]interface{}))
			if err != nil {
				return result, err
			}
			result.cmds = cmds

		case userMod:
			cmds, err := modules.NewUser(task[key].(map[string]interface{}))
			if err != nil {
				return result, err
			}
			result.cmds = cmds
		case shellMod:
			cmds, err := modules.NewShell(task[key].(map[string]interface{}))
			if err != nil {
				return result, err
			}
			result.cmds = cmds
		case "skip_errors":
			result.skip_errors = true
		case "name":
			result.name = task[key].(string)
		case "default":
			fmt.Println(key)
		}
	}
	return result, nil
}

```
Here you can see that we have methods for shell tasks, user and group manipulation, as well as lineinfile, which is used to add lines to existing files or check whether a line is present in a file.
The implementation can be found [here](https://github.com/kdsama/gansible). 

In the next article, we will see how to run all the tasks together, using different strategies.