According to ansible docs, starting from Ansible 2.2 we can use binary modules which means any compiled language can be used. (source: https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#binary-modules)
Let’s write a basic echo module in both Python and Rust and compare them.
Python module
Writing modules in python is pretty easy since there are a lot of builtin classes from the Ansible itself:
#!/usr/bin/python
# coding: utf-8
from __future__ import absolute_import, annotations, division, print_function
__metaclass__ = type
DOCUMENTATION = r"""
---
module: py_echo
author: abakanovskii
short_description: Echo some string
description:
- This module echoes strings.
options:
msg:
description:
- Message to echo.
type: str
required: true
"""
EXAMPLES = r"""
- name: Echo 123
py_echo:
msg: 123
"""
RETURN = r"""
msg:
description: Message
returned: always
type: string
sample: 123
"""
from ansible.module_utils.basic import AnsibleModule
def main():
arg_spec = dict(
msg=dict(type='str', required=True,),
)
module = AnsibleModule(
supports_check_mode=True,
argument_spec=arg_spec,
)
module.exit_json(msg=module.params['msg'])
if __name__ == '__main__':
main()
The module is pretty simple: it takes msg
as a required argument and returns it.
Rust module
In Rust we must implement required structs and functions, but thankfully there are an example from Ansible itself. Module reads from file first because ansible runs modules with a path to file, which contains all arguments in a JSON format.
Create project
Let’s create a project and write code in a src/main.rs
:
cargo new ansible_mod
Code:
use serde::{Deserialize, Serialize};
use serde_json::Error;
use std::{
env,
process,
fs::File,
io::Read,
};
#[derive(Serialize, Deserialize)]
struct ModuleArgs {
msg: String,
}
#[derive(Clone, Serialize, Deserialize)]
struct Response {
msg: String,
changed: bool,
failed: bool,
}
fn exit_json(response_body: Response) {
return_response(response_body)
}
fn fail_json(response_body: Response) {
let failed_response: &mut Response = &mut response_body.clone();
failed_response.failed = true;
return_response(failed_response.clone())
}
fn return_response(resp: Response) {
println!("{}", serde_json::to_string(&resp).unwrap());
process::exit(resp.failed as i32);
}
fn read_file_contents(file_name: &str) -> Result<String, Box<std::io::Error>> {
let mut json_string: String = String::new();
File::open(file_name)?.read_to_string(&mut json_string)?;
Ok(json_string)
}
fn parse_module_args(json_input: String) -> Result<ModuleArgs, Error> {
Ok(
ModuleArgs::from(
serde_json::from_str(
json_input.as_str()
)?
)
)
}
fn main() {
let args: Vec<String> = env::args().collect();
let program: &String = &args[0];
let input_file_name: &str = match args.len() {
2 => &args[1],
_ => {
eprintln!("Module '{program}' expects exactly one argument!");
fail_json(Response {
msg: "No module arguments file provided".to_string(),
changed: false,
failed: true,
});
""
}
};
let json_input: String = read_file_contents(input_file_name).map_err(|err| {
eprintln!("Could not read file '{input_file_name}': {err}");
fail_json(Response {
msg: format!("Could not read input JSON file '{input_file_name}': {err}"),
changed: false,
failed: true,
})
}).unwrap();
let module_args: ModuleArgs = parse_module_args(json_input).map_err(|err| {
eprintln!("Error during parsing JSON module arguments: {err}");
fail_json(Response {
msg: format!("Malformed input JSON module arguments: {err}"),
changed: false,
failed: true,
})
}).unwrap();
exit_json(Response {
msg: format!("{}", &module_args.msg),
changed: false,
failed: false,
});
}
Build
Build it:
cargo build -r
cp target/release/ansible_mod .
Test and compare
To run modules locally without using collections or roles we must specify the ANSIBLE_LIBRARY
environment varible for each command. To keep it simple we use pwd
result as a value:
ANSIBLE_LIBRARY=$(pwd) ansible localhost -m py_echo -a msg=123
ANSIBLE_LIBRARY=$(pwd) ansible localhost -m ansible_mod -a msg=123
Result:
Now let’s compare performance of these two with a hyperfine:
Obviously the Rust module is faster and the more complex the module get, the more complex the module becomes, the more noticeable the difference in speed will be. (Ansible itself takes a lot of time while connecting, executing and etc)
But as I mentioned earlier, writing modules in Python is much easier, due to the abundance of built-in methods and classes for their operation. In the Python code I wrote a little documentation for the module which I can view with ansible-doc
:
ANSIBLE_LIBRARY=$(pwd) ansible-doc -t module py_echo.py
But I cannot do the same with a compiled Rust module. Of course, you can create a stub file ansible_mod.py
with only the documentation variables, but there are other tools that are written only for python (for example, ansible-lint
)
Conclusion
For heavy operations, you can rewrite/write a module in a compiled language, but rewriting all modules for the sake of minor speedup does not make much sense, for a number of reasons:
- In Ansible, Python has a developed ecosystem and a large number of examples
- Python modules can be changed on the fly (the user can find an error in the code and check it himself)
- Distributing modules in Python is much easier and safer
If the goal is to significantly speed up the overall execution time of a playbook, then the bottleneck will ultimately be in Ansible itself, not in its individual modules.
There was an attempt to rewrite Ansible in Rust by the creator himself, but the project was soon abandoned as it did not find much demand in the community. (see: https://github.com/jetporch/jetporch)