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.
In the next article, we will see how to run all the tasks together, using different strategies.
Top comments (0)