After a bit of research I've solved my issue. Here is how I did it.
Before code a little note about architecture for what it was created - I've got a couple of servers running headless VirtualBoxes (ci_vm_servers
section in hosts
file, for example 192.168.3.1
), each of them run Vagrant to create and control few virtual machines (ci_vm_nodes
section in hosts
file, for example 192.168.3.11
).
Now, the code (based on https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.html). Following example code shows module which can execute commands, for example pull Vagrant repo, on all VM servers at once.
#!/usr/bin/python
# Copyright: (c) 2019, Dmitriy Vinokurov <gim6626@gmail.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
ANSIBLE_METADATA = {
'metadata_version': '0.1',
'status': ['preview'],
'supported_by': 'community'
}
DOCUMENTATION = '''
---
module: vagrant
short_description: Ansible Vagrant module
version_added: "2.9"
description:
- "Module to control remote servers running Vagrant"
options:
command:
description:
- This is the command to send to the remote servers
required: true
author:
- Dmitriy Vinokurov (@gim6626)
'''
RETURN = r''' # '''
from ansible.module_utils.basic import AnsibleModule
def run_module():
# define available arguments/parameters a user can pass to the module
module_args = dict(
command=dict(type='str', required=True),
remote_host=dict(type='str', required=True),
)
# seed the result dict in the object
# we primarily care about changed and state
# change is if this module effectively modified the target
# state will include any data that you want your module to pass back
# for consumption, for example, in a subsequent task
result = dict(
changed=False,
original_message='',
message=''
)
# the AnsibleModule object will be our abstraction working with Ansible
# this includes instantiation, a couple of common attr would be the
# args/params passed to the execution, as well as if the module
# supports check mode
module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True
)
# if the user is working with this module in only check mode we do not
# want to make any changes to the environment, just return the current
# state with no modifications
if module.check_mode:
module.exit_json(**result)
command = module.params['command']
remote_host = module.params['remote_host']
vagrant_manager = VagrantManager(module, remote_host, command, result)
if command == 'pull':
vagrant_manager.pull_vagrant_repo_on_vm_server()
elif command == 'provision':
vagrant_manager.vagrant_provision_on_vm_server()
else:
module.fail_json(msg='Unsupported command "{}"'.format(command),
**vagrant_manager.result)
# use whatever logic you need to determine whether or not this module
# made any modifications to your target
result['changed'] = True
# in the event of a successful module execution, you will want to
# simple AnsibleModule.exit_json(), passing the key/value results
module.exit_json(**result)
class VagrantManager:
SUBCOMMAND_ERROR_MSG_TEMPLATE = 'Subcommand "{}" failed while trying to execute "{}" command'
INVALID_REMOTE_HOST_TYPE_ERROR_MSG_TEMPLATE = 'Invalid remote host "{}" type, expected {}'
VM_SERVER_TYPE = 'VM Server'
VM_NODE_TYPE = 'VM Node'
def __init__(self, module, remote_host, command, result):
self.module = module
self.remote_host = remote_host
self.command = command
self.result = result
# manipulate or modify the state as needed (this is going to be the
# part where your module will do what it needs to do)
self.result['original_message'] = 'Execute "{}"'.format(self.command)
self.result['command'] = self.command
self.result['message'] = 'OK'
self.result['remote_host'] = self.remote_host
def pull_vagrant_repo_on_vm_server(self):
self._check_if_remote_host_is_vm_server()
self._run_sub_command('git pull', '/home/vbox/ci-vagrant')
def vagrant_provision_on_vm_server(self):
self._check_if_remote_host_is_vm_server()
self._run_sub_command('vagrant provision', '/home/vbox/ci-vagrant')
def _run_sub_command(self, sub_command, cwd=None):
rc, stdout, stderr = self.module.run_command(sub_command, cwd=cwd)
if rc != 0:
self.result['stdout'] = stdout
self.result['stderr'] = stderr
self.module.fail_json(msg=self.SUBCOMMAND_ERROR_MSG_TEMPLATE.format(sub_command, self.command),
**self.result)
def _check_if_remote_host_is_vm_server(self):
remote_host_type = self._guess_remote_host_type()
if remote_host_type != VagrantManager.VM_SERVER_TYPE:
module.fail_json(msg=INVALID_REMOTE_HOST_TYPE_ERROR_MSG_TEMPLATE(module.params['remote_host'], VM_SERVER_TYPE),
**result)
def _check_if_remote_host_is_vm_node(self):
remote_host_type = self._guess_remote_host_type()
if remote_host_type != VagrantManager.VM_NODE_TYPE:
module.fail_json(msg=INVALID_REMOTE_HOST_TYPE_ERROR_MSG_TEMPLATE(module.params['remote_host'], VM_NODE_TYPE),
**result)
def _guess_remote_host_type(self):
if '192.168.3.' not in self.remote_host:
self.module.fail_json(msg='Wrong remote host "{}", it looks like neither VM server nor VM node'.format(self.remote_host),
**self.result)
elif len(self.remote_host.split('.')[-1]) == 1:
return VagrantManager.VM_SERVER_TYPE
else:
return VagrantManager.VM_NODE_TYPE
def main():
run_module()
if __name__ == '__main__':
main()
To prepare execution:
- Follow instructions on https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.html#common-environment-setup
- Create file
lib/ansible/modules/cloud/vagrant/vagrant.py
in cloned repo with content shown above
Finally, you could pull Vagrant repo on all servers using ansible ci_vm_servers -m vagrant -a 'command=pull remote_host={{ inventory_hostname }}' -u vbox --ask-pass -i hosts
, assuming that: 1) you have ci_vm_servers
section with IPs list in hosts
file as noted above; 2) user vbox
on all servers is set to pull without specifying password.
There are only commands for servers in example, not for VMs, but it could be easily improved. up/halt/reboot
commands to execute for all VMs on all servers also could be added without problems.