Abstract to Go: Lets create our own Ansible (Part 1)

dhingrachief

Kshitij (kd)

Posted on October 30, 2023

Abstract to Go: Lets create our own Ansible (Part 1)

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  

Enter fullscreen mode Exit fullscreen mode

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       

Enter fullscreen mode Exit fullscreen mode

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

}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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.

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

💖 💪 🙅 🚩
dhingrachief
Kshitij (kd)

Posted on October 30, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related