Ansible and Qemu/KVM: Streamlining Virtual Machine Operations

While this may not be a typical use case, if you need to run commands within a Guest Virtual Machine using Ansible, this post is for you. Here is a sample playbook. However, this playbook would not work unless a custom module is configured.

- name: Deploy VM using Cloud-init
  hosts: localhost
  gather_facts: yes
  become: yes
  tasks:
  - name: "Run command on the Guest"
    run_vm_command:
      domain: test-vm
      command: ["apt","update"]
      timeout: 30
    register: foo

  - debug: var=foo

You must create a custom module to talk with the guest VM. If the name of the playbook is “test.yaml” then the module must be created in a directory called “library” at the same level as the playbook.

.
├── library

│   └── run_vm_command.py
└── test.yaml

1 directory, 2 files

The code for run_vm_command.py is as follows:

#!/usr/bin/python3


from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
from ansible.module_utils.basic import AnsibleModule
import json
import subprocess
import base64
import sys
import time
from datetime import datetime

'''
To print step-by-step data, use only for debugging
'''
DEBUG=False


'''
just in case you need to print step by step data
'''
def debug_print(data):
   if DEBUG:
       print(str(data))

'''
Check the command running status
'''
def get_status(module, domain,pid):

    #build the JSON body for PID status check
    my_cmd_json_pid=json.dumps(([{"execute": "guest-exec-status", "arguments": { "pid": pid }}])[0])
    debug_print(my_cmd_json_pid)

    #Get the status of the PID
    result_pid = subprocess.run(['virsh', '-c','qemu:///system','qemu-agent-command', domain, my_cmd_json_pid],  capture_output=True, text=True, encoding="utf-8")
    debug_print(result_pid)

    if json.loads(result_pid.stdout)["return"]["exited"]:
        return True, json.loads(result_pid.stdout)
    else:
        return False,json.loads(result_pid.stdout)

def run_cmd(module, domain,cmd,args):
    if len(args) == 0:
        args=[]
    my_cmd_json=json.dumps([{"execute": "guest-exec", "arguments": { "path": str(cmd), "arg": args, "capture-output": True }}][0])
    debug_print(my_cmd_json)

    # Run the command, with above JSON
    cmd_run_result = subprocess.run(['virsh', '-c','qemu:///system','qemu-agent-command', domain, my_cmd_json],  capture_output=True, text=True, encoding="utf-8")
    if cmd_run_result.returncode != 0:
        print(cmd_run_result.stderr,file=sys.stderr)
        sys.exit(cmd_run_result.returncode)
    debug_print(cmd_run_result)

    #extract the PID from then returned json body
    try:
        pid=json.loads(cmd_run_result.stdout)["return"]["pid"]
        debug_print(pid)
    except Exception as e:
        print(e)
        print([cmd_run_result, e],file=sys.stderr)
        sys.exit(cmd_run_result.returncode)

    return cmd_run_result, pid


def parse_stdout_stderr(module, raw_out):
    try:
        base64_out=base64.b64decode(raw_out["return"]["out-data"]).decode('UTF-8')
    except KeyError as e:
        base64_out=""
    try:
        base64_err=base64.b64decode(raw_out["return"]["err-data"]).decode('UTF-8')
    except KeyError as e:
        base64_err=""
    return base64_out, base64_err


def main():
    module_args = dict(
        domain=dict(type='str', required=True),
        command=dict(type='list', required=True),
        timeout=dict(type='int', required=True),
    )
    module = AnsibleModule(
        argument_spec=module_args,
        supports_check_mode=True
    )

    result = dict(
        msg = '',
        stdout = '',
        stdout_lines = [],
        stderr = '',
        stderr_lines = [],
        rc = 0,
        failed = False,
        changed=False
    )
    domain =  module.params['domain']
    command =  module.params['command']
    command_timeout = module.params['timeout']

    cmd=command[0]
    args=command[1:]

    #running the command
    result_cmd, pid = run_cmd(module,domain,cmd,args)


    #wait till command is finished,check every 1 sec
    cmd_exited=False
    wait_counter=0
    start_time = datetime.now()
    while not cmd_exited:
        cmd_exited, raw_out = get_status(module, domain, pid)
        time_delta = datetime.now() - start_time
        if time_delta.total_seconds() >= float(command_timeout):
            result['stderr'] =  f"Error: Command timed out"
            result['rc']=99
            module.exit_json(**result)
        time.sleep(1)


    stdout, stderr = parse_stdout_stderr(module, raw_out)
    rc = raw_out["return"]["exitcode"]
    if rc != 0:
        result['failed']=True
    result['msg'] = raw_out
    result['stdout'] = stdout.rstrip()
    result['stdout_lines'] = stdout.rstrip().split('\n')
    result['stderr'] = stderr.rstrip()
    result['stderr_lines'] = stderr.rstrip().split('\n')
    result['rc']=rc
    module.exit_json(**result)
    sys.exit(rc)

if __name__ == '__main__':
    main()

Now, if the playbook is executed, the output is as follows:

ansible-playbook test.yaml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'

PLAY [Deploy VM using Cloud-init] *********************************************************************************************************************************

TASK [Gathering Facts] ********************************************************************************************************************************************
                                                                                                                                         [Sun Jan 15 00:26:37 2023]
ok: [localhost]

TASK [Run command on the Guest] ***********************************************************************************************************************************
                                                                                                                                         [Sun Jan 15 00:26:38 2023]
ok: [localhost]

TASK [debug] ******************************************************************************************************************************************************
                                                                                                                                         [Sun Jan 15 00:26:42 2023]
ok: [localhost] => {
    "foo": {
        "changed": false,
        "failed": false,
        "msg": {
            "return": {
                "err-data": "CldBUk5JTkc6IGFwdCBkb2VzIG5vdCBoYXZlIGEgc3RhYmxlIENMSSBpbnRlcmZhY2UuIFVzZSB3aXRoIGNhdXRpb24gaW4gc2NyaXB0cy4KCg==",
                "exitcode": 0,
                "exited": true,
                "out-data": "SGl0OjEgaHR0cDovL2FyY2hpdmUudWJ1bnR1LmNvbS91YnVudHUgamFtbXkgSW5SZWxlYXNlCkdldDoyIGh0dHA6Ly9hcmNoaXZlLnVidW50dS5jb20vdWJ1bnR1IGphbW15LXVwZGF0ZXMgSW5SZWxlYXNlIFsxMTQga0JdCkdldDozIGh0dHA6Ly9zZWN1cml0eS51YnVudHUuY29tL3VidW50dSBqYW1teS1zZWN1cml0eSBJblJlbGVhc2UgWzExMCBrQl0KR2V0OjQgaHR0cDovL2FyY2hpdmUudWJ1bnR1LmNvbS91YnVudHUgamFtbXktYmFja3BvcnRzIEluUmVsZWFzZSBbOTkuOCBrQl0KRmV0Y2hlZCAzMjQga0IgaW4gMXMgKDQ0NiBrQi9zKQpSZWFkaW5nIHBhY2thZ2UgbGlzdHMuLi4KQnVpbGRpbmcgZGVwZW5kZW5jeSB0cmVlLi4uClJlYWRpbmcgc3RhdGUgaW5mb3JtYXRpb24uLi4KNCBwYWNrYWdlcyBjYW4gYmUgdXBncmFkZWQuIFJ1biAnYXB0IGxpc3QgLS11cGdyYWRhYmxlJyB0byBzZWUgdGhlbS4K"
            }
        },
        "rc": 0,
        "stderr": "\nWARNING: apt does not have a stable CLI interface. Use with caution in scripts.",
        "stderr_lines": [
            "",
            "WARNING: apt does not have a stable CLI interface. Use with caution in scripts."
        ],
        "stdout": "Hit:1 http://archive.ubuntu.com/ubuntu jammy InRelease\nGet:2 http://archive.ubuntu.com/ubuntu jammy-updates InRelease [114 kB]\nGet:3 http://security.ubuntu.com/ubuntu jammy-security InRelease [110 kB]\nGet:4 http://archive.ubuntu.com/ubuntu jammy-backports InRelease [99.8 kB]\nFetched 324 kB in 1s (446 kB/s)\nReading package lists...\nBuilding dependency tree...\nReading state information...\n4 packages can be upgraded. Run 'apt list --upgradable' to see them.",
        "stdout_lines": [
            "Hit:1 http://archive.ubuntu.com/ubuntu jammy InRelease",
            "Get:2 http://archive.ubuntu.com/ubuntu jammy-updates InRelease [114 kB]",
            "Get:3 http://security.ubuntu.com/ubuntu jammy-security InRelease [110 kB]",
            "Get:4 http://archive.ubuntu.com/ubuntu jammy-backports InRelease [99.8 kB]",
            "Fetched 324 kB in 1s (446 kB/s)",
            "Reading package lists...",
            "Building dependency tree...",
            "Reading state information...",
            "4 packages can be upgraded. Run 'apt list --upgradable' to see them."
        ]
    }
}

PLAY RECAP ********************************************************************************************************************************************************
localhost                  : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

0 0 votes
Please, Rate this post
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x
Scroll to Top