From 24e284426c888fcc8033c56c08327f7b8fb7ec2b Mon Sep 17 00:00:00 2001 From: Nils <31704359+mietzen@users.noreply.github.com> Date: Mon, 8 Sep 2025 19:06:06 +0200 Subject: [PATCH 01/10] Create proxmox_qm_remote.py --- plugins/connection/proxmox_qm_remote.py | 907 ++++++++++++++++++++++++ 1 file changed, 907 insertions(+) create mode 100644 plugins/connection/proxmox_qm_remote.py diff --git a/plugins/connection/proxmox_qm_remote.py b/plugins/connection/proxmox_qm_remote.py new file mode 100644 index 00000000..2f1f38e9 --- /dev/null +++ b/plugins/connection/proxmox_qm_remote.py @@ -0,0 +1,907 @@ +# -*- coding: utf-8 -*- +# Derived from ansible/plugins/connection/paramiko_ssh.py (c) 2012, Michael DeHaan +# Copyright (c) 2024 Nils Stein (@mietzen) +# Copyright (c) 2024 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +author: Nils Stein (@mietzen) +name: proxmox_qm_remote +short_description: Run tasks in Proxmox VM instances using qm CLI via SSH +requirements: + - paramiko +description: + - Run commands or put/fetch files to an existing Proxmox VM using qm CLI via SSH. + - Uses the Python SSH implementation (Paramiko) to connect to the Proxmox host. + - Supports chunked file transfers for large files using qm guest exec commands. +options: + remote_addr: + description: + - Address of the remote target. + default: inventory_hostname + type: string + vars: + - name: inventory_hostname + - name: ansible_host + - name: ansible_ssh_host + - name: ansible_paramiko_host + port: + description: Remote port to connect to. + type: int + default: 22 + ini: + - section: defaults + key: remote_port + - section: paramiko_connection + key: remote_port + env: + - name: ANSIBLE_REMOTE_PORT + - name: ANSIBLE_REMOTE_PARAMIKO_PORT + vars: + - name: ansible_port + - name: ansible_ssh_port + - name: ansible_paramiko_port + keyword: + - name: port + remote_user: + description: + - User to login/authenticate as. + - Can be set from the CLI via the C(--user) or C(-u) options. + type: string + vars: + - name: ansible_user + - name: ansible_ssh_user + - name: ansible_paramiko_user + env: + - name: ANSIBLE_REMOTE_USER + - name: ANSIBLE_PARAMIKO_REMOTE_USER + ini: + - section: defaults + key: remote_user + - section: paramiko_connection + key: remote_user + keyword: + - name: remote_user + password: + description: + - Secret used to either login the SSH server or as a passphrase for SSH keys that require it. + - Can be set from the CLI via the C(--ask-pass) option. + type: string + vars: + - name: ansible_password + - name: ansible_ssh_pass + - name: ansible_ssh_password + - name: ansible_paramiko_pass + - name: ansible_paramiko_password + use_rsa_sha2_algorithms: + description: + - Whether or not to enable RSA SHA2 algorithms for pubkeys and hostkeys. + - On paramiko versions older than 2.9, this only affects hostkeys. + - For behavior matching paramiko<2.9 set this to V(false). + vars: + - name: ansible_paramiko_use_rsa_sha2_algorithms + ini: + - {key: use_rsa_sha2_algorithms, section: paramiko_connection} + env: + - {name: ANSIBLE_PARAMIKO_USE_RSA_SHA2_ALGORITHMS} + default: true + type: boolean + host_key_auto_add: + description: "Automatically add host keys to C(~/.ssh/known_hosts)." + env: + - name: ANSIBLE_PARAMIKO_HOST_KEY_AUTO_ADD + ini: + - key: host_key_auto_add + section: paramiko_connection + type: boolean + look_for_keys: + default: True + description: "Set to V(false) to disable searching for private key files in C(~/.ssh/)." + env: + - name: ANSIBLE_PARAMIKO_LOOK_FOR_KEYS + ini: + - {key: look_for_keys, section: paramiko_connection} + type: boolean + proxy_command: + default: "" + description: + - Proxy information for running the connection via a jumphost. + type: string + env: + - name: ANSIBLE_PARAMIKO_PROXY_COMMAND + ini: + - {key: proxy_command, section: paramiko_connection} + vars: + - name: ansible_paramiko_proxy_command + pty: + default: True + description: "C(sudo) usually requires a PTY, V(true) to give a PTY and V(false) to not give a PTY." + env: + - name: ANSIBLE_PARAMIKO_PTY + ini: + - section: paramiko_connection + key: pty + type: boolean + record_host_keys: + default: True + description: "Save the host keys to a file." + env: + - name: ANSIBLE_PARAMIKO_RECORD_HOST_KEYS + ini: + - section: paramiko_connection + key: record_host_keys + type: boolean + host_key_checking: + description: "Set this to V(false) if you want to avoid host key checking by the underlying tools Ansible uses to connect to the host." + type: boolean + default: true + env: + - name: ANSIBLE_HOST_KEY_CHECKING + - name: ANSIBLE_SSH_HOST_KEY_CHECKING + - name: ANSIBLE_PARAMIKO_HOST_KEY_CHECKING + ini: + - section: defaults + key: host_key_checking + - section: paramiko_connection + key: host_key_checking + vars: + - name: ansible_host_key_checking + - name: ansible_ssh_host_key_checking + - name: ansible_paramiko_host_key_checking + use_persistent_connections: + description: "Toggles the use of persistence for connections." + type: boolean + default: False + env: + - name: ANSIBLE_USE_PERSISTENT_CONNECTIONS + ini: + - section: defaults + key: use_persistent_connections + forward_agent: + description: "Enable SSH agent forwarding." + type: boolean + default: False + env: + - name: ANSIBLE_PARAMIKO_FORWARD_AGENT + ini: + - section: paramiko_connection + key: forward_agent + banner_timeout: + type: float + default: 30 + description: + - Configures, in seconds, the amount of time to wait for the SSH + banner to be presented. This option is supported by paramiko + version 1.15.0 or newer. + ini: + - section: paramiko_connection + key: banner_timeout + env: + - name: ANSIBLE_PARAMIKO_BANNER_TIMEOUT + timeout: + type: int + default: 10 + description: Number of seconds until the plugin gives up on failing to establish a TCP connection. + ini: + - section: defaults + key: timeout + - section: ssh_connection + key: timeout + - section: paramiko_connection + key: timeout + env: + - name: ANSIBLE_TIMEOUT + - name: ANSIBLE_SSH_TIMEOUT + - name: ANSIBLE_PARAMIKO_TIMEOUT + vars: + - name: ansible_ssh_timeout + - name: ansible_paramiko_timeout + cli: + - name: timeout + lock_file_timeout: + type: int + default: 60 + description: Number of seconds until the plugin gives up on trying to write a lock file when writing SSH known host keys. + vars: + - name: ansible_lock_file_timeout + env: + - name: ANSIBLE_LOCK_FILE_TIMEOUT + private_key_file: + description: + - Path to private key file to use for authentication. + type: string + ini: + - section: defaults + key: private_key_file + - section: paramiko_connection + key: private_key_file + env: + - name: ANSIBLE_PRIVATE_KEY_FILE + - name: ANSIBLE_PARAMIKO_PRIVATE_KEY_FILE + vars: + - name: ansible_private_key_file + - name: ansible_ssh_private_key_file + - name: ansible_paramiko_private_key_file + cli: + - name: private_key_file + option: "--private-key" + vmid: + description: + - VM ID + type: int + vars: + - name: proxmox_vmid + proxmox_become_method: + description: + - Become command used in proxmox + type: str + default: sudo + vars: + - name: proxmox_become_method + qm_file_chunk_size_put: + description: + - Chunk size for putting files (in bytes). Maximum is 1MiB-1. + type: int + default: 1048575 + vars: + - name: proxmox_qm_file_chunk_size_put + qm_file_chunk_size_fetch: + description: + - Chunk size for fetching files (in bytes). Recommended is 2MiB. + type: int + default: 2097152 + vars: + - name: proxmox_qm_file_chunk_size_fetch + qm_timeout: + description: + - Timeout for qm guest exec commands in seconds. + type: int + default: 60 + vars: + - name: proxmox_qm_timeout +notes: + - > + When NOT using this plugin as root, you need to have a become mechanism, + e.g. C(sudo), installed on Proxmox and setup so we can run it without prompting for the password. + Inside the VM, we need a shell and commands like C(cat), C(dd), C(stat), C(base64), and C(sha256sum) + available in the C(PATH) for this plugin to work with file transfers. + - > + The VM must have QEMU guest agent installed and running. + - > + Only Linux and FreeBSD VMs are supported. + - > + File transfers are relatively slow (90-350 KB/s) due to the chunked transfer mechanism through qm guest exec. +""" + +EXAMPLES = r""" +# -------------------------------- +# Static inventory file: hosts.yml +# -------------------------------- +# all: +# children: +# vms: +# hosts: +# vm-1: +# ansible_host: 10.0.0.10 +# proxmox_vmid: 100 +# ansible_connection: community.proxmox.proxmox_qm_remote +# ansible_user: ansible +# vm-2: +# ansible_host: 10.0.0.10 +# proxmox_vmid: 200 +# ansible_connection: community.proxmox.proxmox_qm_remote +# ansible_user: ansible +# proxmox: +# hosts: +# proxmox-1: +# ansible_host: 10.0.0.10 +# +# ---------------------- +# Playbook: playbook.yml +# ---------------------- +--- +- hosts: vms + tasks: + - name: Ping VM + ansible.builtin.ping: + + - name: Copy file to VM + ansible.builtin.copy: + src: ./local_file.txt + dest: /tmp/remote_file.txt + + - name: Fetch file from VM + ansible.builtin.fetch: + src: /tmp/remote_file.txt + dest: ./fetched_file.txt + flat: yes +""" + +import base64 +import hashlib +import json +import os +import pathlib +import socket +import tempfile +import traceback +import typing as t + +from ansible.errors import ( + AnsibleAuthenticationFailure, + AnsibleConnectionFailure, + AnsibleError, +) +from ansible_collections.community.proxmox.plugins.module_utils._filelock import FileLock, LockTimeout +from ansible_collections.community.proxmox.plugins.module_utils.version import LooseVersion +from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text +from ansible.plugins.connection import ConnectionBase +from ansible.utils.display import Display +from ansible.utils.path import makedirs_safe +from binascii import hexlify + +try: + import paramiko + PARAMIKO_IMPORT_ERR = None +except ImportError: + paramiko = None + PARAMIKO_IMPORT_ERR = traceback.format_exc() + + +display = Display() + + +def authenticity_msg(hostname: str, ktype: str, fingerprint: str) -> str: + msg = f""" + paramiko: The authenticity of host '{hostname}' can't be established. + The {ktype} key fingerprint is {fingerprint}. + Are you sure you want to continue connecting (yes/no)? + """ + return msg + + +MissingHostKeyPolicy: type = object +if paramiko: + MissingHostKeyPolicy = paramiko.MissingHostKeyPolicy + + +class MyAddPolicy(MissingHostKeyPolicy): + """ + Based on AutoAddPolicy in paramiko so we can determine when keys are added + and also prompt for input. + """ + + def __init__(self, connection: Connection) -> None: + self.connection = connection + self._options = connection._options + + def missing_host_key(self, client, hostname, key) -> None: + if all((self.connection.get_option('host_key_checking'), not self.connection.get_option('host_key_auto_add'))): + fingerprint = hexlify(key.get_fingerprint()) + ktype = key.get_name() + + if self.connection.get_option('use_persistent_connections') or self.connection.force_persistence: + raise AnsibleError(authenticity_msg(hostname, ktype, fingerprint)[1:92]) + + inp = to_text( + display.prompt_until(authenticity_msg(hostname, ktype, fingerprint), private=False), + errors='surrogate_or_strict' + ) + + if inp.lower() not in ['yes', 'y', '']: + raise AnsibleError('host connection rejected by user') + + key._added_by_ansible_this_time = True + client._host_keys.add(hostname, key.get_name(), key) + + +class Connection(ConnectionBase): + """ SSH based connections (paramiko) to Proxmox qm """ + + transport = 'community.proxmox.proxmox_qm_remote' + _log_channel: str | None = None + + def __init__(self, play_context, new_stdin, *args, **kwargs): + super(Connection, self).__init__(play_context, new_stdin, *args, **kwargs) + + def _set_log_channel(self, name: str) -> None: + """ Mimic paramiko.SSHClient.set_log_channel """ + self._log_channel = name + + def _parse_proxy_command(self, port: int = 22) -> dict[str, t.Any]: + proxy_command = self.get_option('proxy_command') or None + + sock_kwarg = {} + if proxy_command: + replacers = { + '%h': self.get_option('remote_addr'), + '%p': port, + '%r': self.get_option('remote_user') + } + for find, replace in replacers.items(): + proxy_command = proxy_command.replace(find, str(replace)) + try: + sock_kwarg = {'sock': paramiko.ProxyCommand(proxy_command)} + display.vvv(f'CONFIGURE PROXY COMMAND FOR CONNECTION: {proxy_command}', host=self.get_option('remote_addr')) + except AttributeError: + display.warning('Paramiko ProxyCommand support unavailable. ' + 'Please upgrade to Paramiko 1.9.0 or newer. ' + 'Not using configured ProxyCommand') + + return sock_kwarg + + def _connect(self) -> Connection: + """ activates the connection object """ + + if PARAMIKO_IMPORT_ERR is not None: + raise AnsibleError(f'paramiko is not installed: {to_native(PARAMIKO_IMPORT_ERR)}') + + port = self.get_option('port') + display.vvv(f'ESTABLISH PARAMIKO SSH CONNECTION FOR USER: {self.get_option("remote_user")} on PORT {to_text(port)} TO {self.get_option("remote_addr")}', + host=self.get_option('remote_addr')) + + ssh = paramiko.SSHClient() + + # Set pubkey and hostkey algorithms + paramiko_preferred_pubkeys = getattr(paramiko.Transport, '_preferred_pubkeys', ()) + paramiko_preferred_hostkeys = getattr(paramiko.Transport, '_preferred_keys', ()) + use_rsa_sha2_algorithms = self.get_option('use_rsa_sha2_algorithms') + disabled_algorithms: t.Dict[str, t.Iterable[str]] = {} + if not use_rsa_sha2_algorithms: + if paramiko_preferred_pubkeys: + disabled_algorithms['pubkeys'] = tuple(a for a in paramiko_preferred_pubkeys if 'rsa-sha2' in a) + if paramiko_preferred_hostkeys: + disabled_algorithms['keys'] = tuple(a for a in paramiko_preferred_hostkeys if 'rsa-sha2' in a) + + if self._log_channel is not None: + ssh.set_log_channel(self._log_channel) + + self.keyfile = os.path.expanduser('~/.ssh/known_hosts') + + if self.get_option('host_key_checking'): + for ssh_known_hosts in ('/etc/ssh/ssh_known_hosts', '/etc/openssh/ssh_known_hosts'): + try: + ssh.load_system_host_keys(ssh_known_hosts) + break + except IOError: + pass + except paramiko.hostkeys.InvalidHostKey as e: + raise AnsibleConnectionFailure(f'Invalid host key: {to_text(e.line)}') + try: + ssh.load_system_host_keys() + except paramiko.hostkeys.InvalidHostKey as e: + raise AnsibleConnectionFailure(f'Invalid host key: {to_text(e.line)}') + + ssh_connect_kwargs = self._parse_proxy_command(port) + ssh.set_missing_host_key_policy(MyAddPolicy(self)) + conn_password = self.get_option('password') + allow_agent = True + + if conn_password is not None: + allow_agent = False + + try: + key_filename = None + if self.get_option('private_key_file'): + key_filename = os.path.expanduser(self.get_option('private_key_file')) + + if LooseVersion(paramiko.__version__) >= LooseVersion('2.2.0'): + ssh_connect_kwargs['auth_timeout'] = self.get_option('timeout') + + if LooseVersion(paramiko.__version__) >= LooseVersion('1.15.0'): + ssh_connect_kwargs['banner_timeout'] = self.get_option('banner_timeout') + + ssh.connect( + self.get_option('remote_addr').lower(), + username=self.get_option('remote_user'), + allow_agent=allow_agent, + look_for_keys=self.get_option('look_for_keys'), + key_filename=key_filename, + password=conn_password, + timeout=self.get_option('timeout'), + port=port, + disabled_algorithms=disabled_algorithms, + **ssh_connect_kwargs, + ) + except paramiko.ssh_exception.BadHostKeyException as e: + raise AnsibleConnectionFailure(f'host key mismatch for {to_text(e.hostname)}') + except paramiko.ssh_exception.AuthenticationException as e: + msg = f'Failed to authenticate: {e}' + raise AnsibleAuthenticationFailure(msg) + except Exception as e: + msg = to_text(e) + if u'PID check failed' in msg: + raise AnsibleError('paramiko version issue, please upgrade paramiko on the machine running ansible') + elif u'Private key file is encrypted' in msg: + msg = f'ssh {self.get_option("remote_user")}@{self.get_option("remote_addr")}:{port} : ' + \ + f'{msg}\nTo connect as a different user, use -u .' + raise AnsibleConnectionFailure(msg) + else: + raise AnsibleConnectionFailure(msg) + + self.ssh = ssh + self._connected = True + return self + + def _any_keys_added(self) -> bool: + for hostname, keys in self.ssh._host_keys.items(): + for keytype, key in keys.items(): + added_this_time = getattr(key, '_added_by_ansible_this_time', False) + if added_this_time: + return True + return False + + def _save_ssh_host_keys(self, filename: str) -> None: + """Save SSH host keys to file""" + if not self._any_keys_added(): + return + + path = os.path.expanduser('~/.ssh') + makedirs_safe(path) + + with open(filename, 'w') as f: + for hostname, keys in self.ssh._host_keys.items(): + for keytype, key in keys.items(): + added_this_time = getattr(key, '_added_by_ansible_this_time', False) + if not added_this_time: + f.write(f'{hostname} {keytype} {key.get_base64()}\n') + + for hostname, keys in self.ssh._host_keys.items(): + for keytype, key in keys.items(): + added_this_time = getattr(key, '_added_by_ansible_this_time', False) + if added_this_time: + f.write(f'{hostname} {keytype} {key.get_base64()}\n') + + def _build_qm_command(self, cmd: str) -> str: + """Build qm guest exec command""" + qm_cmd = ['/usr/sbin/qm', 'guest', 'exec', str(self.get_option('vmid')), '--', cmd] + if self.get_option('remote_user') != 'root': + qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd + display.vvv(f'INFO Running as non root user: {self.get_option("remote_user")}, trying to run qm with become method: ' + + f'{self.get_option("proxmox_become_method")}', + host=self.get_option('remote_addr')) + return ' '.join(qm_cmd) + + def _qm_exec(self, cmd: list[str], data_in: bytes | None = None, timeout: int | None = None) -> str | None: + """Execute command inside VM via qm guest exec and return output""" + if timeout is None: + timeout = self.get_option('qm_timeout') + + qm_cmd = ['/usr/sbin/qm', 'guest', 'exec', str(self.get_option('vmid'))] + + if data_in: + qm_cmd += ['--pass-stdin', '1'] + + qm_cmd += ['--timeout', str(timeout), '--'] + cmd + + if self.get_option('remote_user') != 'root': + qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd + + try: + chan = self.ssh.get_transport().open_session() + chan.exec_command(' '.join(qm_cmd)) + + if data_in: + chan.sendall(data_in) + chan.shutdown_write() + + stdout = b''.join(chan.makefile('rb', 4096)) + stderr = b''.join(chan.makefile_stderr('rb', 4096)) + returncode = chan.recv_exit_status() + + if returncode != 0: + raise AnsibleError(f'qm command failed: {stderr.decode()}') + + if not stdout: + return None + + stdout_json = json.loads(stdout.decode()) + + if stdout_json.get('exitcode') != 0 or stdout_json.get('exited') != 1: + raise AnsibleError(f'VM command failed: {stdout_json}') + + return stdout_json.get('out-data') + + except Exception as e: + raise AnsibleError(f'qm execution failed: {to_text(e)}') + + def _check_guest_agent(self) -> None: + """Check if guest agent is available""" + try: + qm_cmd = ['/usr/sbin/qm', 'guest', 'cmd', str(self.get_option('vmid')), 'ping'] + if self.get_option('remote_user') != 'root': + qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd + + chan = self.ssh.get_transport().open_session() + chan.exec_command(' '.join(qm_cmd)) + returncode = chan.recv_exit_status() + + if returncode != 0: + raise AnsibleError('Guest agent is not installed or not responding') + + except Exception as e: + raise AnsibleError(f'Guest agent check failed: {to_text(e)}') + + def _check_required_commands(self) -> None: + """Check if required commands are available in the VM""" + required_commands = ["cat", "dd", "stat", "base64", "sha256sum"] + for cmd in required_commands: + try: + result = self._qm_exec(['sh', '-c', f'which {cmd}']) + if not result: + raise AnsibleError(f"Command '{cmd}' is not available on the VM") + except Exception: + raise AnsibleError(f"Command '{cmd}' is not available on the VM") + + def exec_command(self, cmd: str, in_data: bytes | None = None, sudoable: bool = True) -> tuple[int, bytes, bytes]: + """ run a command inside the VM """ + + cmd = self._build_qm_command(cmd) + + super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable) + + bufsize = 4096 + + try: + self.ssh.get_transport().set_keepalive(5) + chan = self.ssh.get_transport().open_session() + except Exception as e: + text_e = to_text(e) + msg = 'Failed to open session' + if text_e: + msg += f': {text_e}' + raise AnsibleConnectionFailure(to_native(msg)) + + if self.get_option('pty') and sudoable: + chan.get_pty(term=os.getenv('TERM', 'vt100'), width=int(os.getenv('COLUMNS', 0)), height=int(os.getenv('LINES', 0))) + + display.vvv(f'EXEC {cmd}', host=self.get_option('remote_addr')) + + if self.get_option('forward_agent'): + paramiko.agent.AgentRequestHandler(chan) + + cmd = to_bytes(cmd, errors='surrogate_or_strict') + + no_prompt_out = b'' + no_prompt_err = b'' + become_output = b'' + + try: + chan.exec_command(cmd) + if self.become and self.become.expect_prompt(): + password_prompt = False + become_success = False + while not (become_success or password_prompt): + display.debug('Waiting for Privilege Escalation input') + + chunk = chan.recv(bufsize) + display.debug(f'chunk is: {to_text(chunk)}') + if not chunk: + if b'unknown user' in become_output: + n_become_user = to_native(self.become.get_option('become_user')) + raise AnsibleError(f'user {n_become_user} does not exist') + else: + break + become_output += chunk + + for line in become_output.splitlines(True): + if self.become.check_success(line): + become_success = True + break + elif self.become.check_password_prompt(line): + password_prompt = True + break + + if password_prompt: + if self.become: + become_pass = self.become.get_option('become_pass') + chan.sendall(to_bytes(become_pass, errors='surrogate_or_strict') + b'\n') + else: + raise AnsibleError('A password is required but none was supplied') + else: + no_prompt_out += become_output + no_prompt_err += become_output + + if in_data: + for i in range(0, len(in_data), bufsize): + chan.send(in_data[i:i + bufsize]) + chan.shutdown_write() + elif in_data == b'': + chan.shutdown_write() + + except socket.timeout: + raise AnsibleError('ssh timed out waiting for privilege escalation.\n' + to_text(become_output)) + + stdout = b''.join(chan.makefile('rb', bufsize)) + stderr = b''.join(chan.makefile_stderr('rb', bufsize)) + returncode = chan.recv_exit_status() + + if 'qm: not found' in stderr.decode('utf-8'): + raise AnsibleError(f'qm not found in path of host: {to_text(self.get_option("remote_addr"))}') + + return (returncode, no_prompt_out + stdout, no_prompt_out + stderr) + + def put_file(self, in_path: str, out_path: str) -> None: + """ transfer a file from local to VM using chunked transfer """ + + display.vvv(f'PUT {in_path} TO {out_path}', host=self.get_option('remote_addr')) + + try: + # Check guest agent and required commands + self._check_guest_agent() + self._check_required_commands() + + file_size = os.path.getsize(in_path) + chunk_size = self.get_option('qm_file_chunk_size_put') + total_chunks = (file_size + chunk_size - 1) // chunk_size + + display.vvv(f'File size: {file_size} bytes. Transferring in {total_chunks} chunks.') + + operator = '>' + + with open(in_path, 'rb') as f: + for chunk_num in range(total_chunks): + chunk = f.read(chunk_size) + if not chunk: + break + + display.vvv(f'Transferring chunk {chunk_num + 1}/{total_chunks} ({len(chunk)} bytes)') + + # Transfer chunk using qm guest exec + self._qm_exec(['sh', '-c', f'cat {operator} {out_path}'], data_in=chunk) + operator = '>>' # After first chunk, append + + # Verify file transfer + try: + remote_size = int(self._qm_exec(['sh', '-c', f'stat --printf="%s" {out_path}']) or '0') + if remote_size != file_size: + raise AnsibleError(f'File size mismatch: local={file_size}, remote={remote_size}') + + # Calculate checksums for verification + local_hash = hashlib.sha256() + with open(in_path, 'rb') as f: + for chunk in iter(lambda: f.read(8192), b""): + local_hash.update(chunk) + local_checksum = local_hash.hexdigest() + + remote_checksum = self._qm_exec(['sh', '-c', f'sha256sum {out_path} | cut -d " " -f 1']).strip() + + if local_checksum != remote_checksum: + raise AnsibleError(f'Checksum mismatch: local={local_checksum}, remote={remote_checksum}') + + except Exception as e: + display.warning(f'File verification failed: {to_text(e)}') + + except Exception as e: + raise AnsibleError(f'error occurred while putting file from {in_path} to {out_path}!\n{to_text(e)}') + + def fetch_file(self, in_path: str, out_path: str) -> None: + """ fetch a file from VM using chunked transfer """ + + display.vvv(f'FETCH {in_path} TO {out_path}', host=self.get_option('remote_addr')) + + try: + # Check guest agent and required commands + self._check_guest_agent() + self._check_required_commands() + + # Get file size + file_size = int(self._qm_exec(['sh', '-c', f'stat --printf="%s" {in_path}']) or '0') + if file_size == 0: + raise AnsibleError(f'File {in_path} does not exist or is empty') + + chunk_size = self.get_option('qm_file_chunk_size_fetch') + blocksize = 4096 + count = int(chunk_size / blocksize) + total_chunks = (file_size + chunk_size - 1) // chunk_size + + display.vvv(f'File size: {file_size} bytes. Fetching in {total_chunks} chunks.') + + transferred_bytes = 0 + + with open(out_path, 'wb') as f: + for chunk_num in range(total_chunks): + display.vvv(f'Fetching chunk {chunk_num + 1}/{total_chunks}') + + # Calculate remaining bytes to transfer + remaining_bytes = file_size - transferred_bytes + current_chunk_size = min(chunk_size, remaining_bytes) + + # Fetch chunk using dd + base64 + cmd = f'dd if={in_path} bs={blocksize} count={count} skip={count * chunk_num} 2>/dev/null | base64 -w0' + chunk_data_b64 = self._qm_exec(['sh', '-c', cmd]) + + if not chunk_data_b64: + break + + # Decode base64 data + chunk_data = base64.standard_b64decode(chunk_data_b64) + + # Trim chunk to actual remaining file size + if len(chunk_data) > remaining_bytes: + chunk_data = chunk_data[:remaining_bytes] + + f.write(chunk_data) + transferred_bytes += len(chunk_data) + + if transferred_bytes >= file_size: + break + + # Verify file transfer + try: + local_size = os.path.getsize(out_path) + if local_size != file_size: + raise AnsibleError(f'File size mismatch: local={local_size}, remote={file_size}') + + # Calculate checksums for verification + local_hash = hashlib.sha256() + with open(out_path, 'rb') as f: + for chunk in iter(lambda: f.read(8192), b""): + local_hash.update(chunk) + local_checksum = local_hash.hexdigest() + + remote_checksum = self._qm_exec(['sh', '-c', f'sha256sum {in_path} | cut -d " " -f 1']).strip() + + if local_checksum != remote_checksum: + raise AnsibleError(f'Checksum mismatch: local={local_checksum}, remote={remote_checksum}') + + except Exception as e: + display.warning(f'File verification failed: {to_text(e)}') + + except Exception as e: + raise AnsibleError(f'error occurred while fetching file from {in_path} to {out_path}!\n{to_text(e)}') + + def reset(self) -> None: + """ reset the connection """ + if not self._connected: + return + self.close() + self._connect() + + def close(self) -> None: + """ terminate the connection """ + + if self.get_option('host_key_checking') and self.get_option('record_host_keys') and self._any_keys_added(): + lockfile = os.path.basename(self.keyfile) + dirname = os.path.dirname(self.keyfile) + makedirs_safe(dirname) + tmp_keyfile_name = None + try: + with FileLock().lock_file(lockfile, dirname, self.get_option('lock_file_timeout')): + self.ssh.load_system_host_keys() + self.ssh._host_keys.update(self.ssh._system_host_keys) + + key_dir = os.path.dirname(self.keyfile) + if os.path.exists(self.keyfile): + key_stat = os.stat(self.keyfile) + mode = key_stat.st_mode & 0o777 + uid = key_stat.st_uid + gid = key_stat.st_gid + else: + mode = 0o644 + uid = os.getuid() + gid = os.getgid() + + with tempfile.NamedTemporaryFile(dir=key_dir, delete=False) as tmp_keyfile: + tmp_keyfile_name = tmp_keyfile.name + os.chmod(tmp_keyfile_name, mode) + os.chown(tmp_keyfile_name, uid, gid) + self._save_ssh_host_keys(tmp_keyfile_name) + + os.rename(tmp_keyfile_name, self.keyfile) + except LockTimeout: + raise AnsibleError( + f'writing lock file for {self.keyfile} ran in to the timeout of {self.get_option("lock_file_timeout")}s') + except paramiko.hostkeys.InvalidHostKey as e: + raise AnsibleConnectionFailure(f'Invalid host key: {e.line}') + except Exception as e: + raise AnsibleError(f'error occurred while writing SSH host keys!\n{to_text(e)}') + finally: + if tmp_keyfile_name is not None: + pathlib.Path(tmp_keyfile_name).unlink(missing_ok=True) + + self.ssh.close() + self._connected = False From 350ae1c8a8e3c37b81064f722104a5e8e935c80f Mon Sep 17 00:00:00 2001 From: Nils Stein <31704359+mietzen@users.noreply.github.com> Date: Sun, 14 Sep 2025 17:05:20 +0200 Subject: [PATCH 02/10] fixed connection plugin --- plugins/connection/proxmox_qm_remote.py | 156 ++++++++++++++---------- 1 file changed, 92 insertions(+), 64 deletions(-) diff --git a/plugins/connection/proxmox_qm_remote.py b/plugins/connection/proxmox_qm_remote.py index 2f1f38e9..5bec5ea6 100644 --- a/plugins/connection/proxmox_qm_remote.py +++ b/plugins/connection/proxmox_qm_remote.py @@ -234,6 +234,13 @@ type: int vars: - name: proxmox_vmid + proxmox_ssh_user: + description: + - Become command used in proxmox + type: str + default: root + vars: + - name: proxmox_ssh_user proxmox_become_method: description: - Become command used in proxmox @@ -266,7 +273,7 @@ - > When NOT using this plugin as root, you need to have a become mechanism, e.g. C(sudo), installed on Proxmox and setup so we can run it without prompting for the password. - Inside the VM, we need a shell and commands like C(cat), C(dd), C(stat), C(base64), and C(sha256sum) + Inside the VM, we need a shell and commands like C(cat), C(dd), C(stat), C(base64), and C(sha256sum) available in the C(PATH) for this plugin to work with file transfers. - > The VM must have QEMU guest agent installed and running. @@ -307,12 +314,12 @@ tasks: - name: Ping VM ansible.builtin.ping: - + - name: Copy file to VM ansible.builtin.copy: src: ./local_file.txt dest: /tmp/remote_file.txt - + - name: Fetch file from VM ansible.builtin.fetch: src: /tmp/remote_file.txt @@ -343,6 +350,12 @@ from ansible.utils.path import makedirs_safe from binascii import hexlify +import os +if os.getenv("ANSIBLE_DEBUGPY") == "1": + import debugpy + debugpy.listen(("0.0.0.0", 5678)) + debugpy.wait_for_client() + try: import paramiko PARAMIKO_IMPORT_ERR = None @@ -496,7 +509,7 @@ def _connect(self) -> Connection: ssh.connect( self.get_option('remote_addr').lower(), - username=self.get_option('remote_user'), + username=self.get_option('proxmox_ssh_user'), allow_agent=allow_agent, look_for_keys=self.get_option('look_for_keys'), key_filename=key_filename, @@ -521,7 +534,7 @@ def _connect(self) -> Connection: raise AnsibleConnectionFailure(msg) else: raise AnsibleConnectionFailure(msg) - + self.ssh = ssh self._connected = True return self @@ -558,9 +571,9 @@ def _save_ssh_host_keys(self, filename: str) -> None: def _build_qm_command(self, cmd: str) -> str: """Build qm guest exec command""" qm_cmd = ['/usr/sbin/qm', 'guest', 'exec', str(self.get_option('vmid')), '--', cmd] - if self.get_option('remote_user') != 'root': + if self.get_option('proxmox_ssh_user') != 'root': qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd - display.vvv(f'INFO Running as non root user: {self.get_option("remote_user")}, trying to run qm with become method: ' + + display.vvv(f'INFO Running as non root user: {self.get_option("proxmox_ssh_user")}, trying to run qm with become method: ' + f'{self.get_option("proxmox_become_method")}', host=self.get_option('remote_addr')) return ' '.join(qm_cmd) @@ -569,42 +582,43 @@ def _qm_exec(self, cmd: list[str], data_in: bytes | None = None, timeout: int | """Execute command inside VM via qm guest exec and return output""" if timeout is None: timeout = self.get_option('qm_timeout') - + qm_cmd = ['/usr/sbin/qm', 'guest', 'exec', str(self.get_option('vmid'))] - + if data_in: qm_cmd += ['--pass-stdin', '1'] - + qm_cmd += ['--timeout', str(timeout), '--'] + cmd - - if self.get_option('remote_user') != 'root': + + if self.get_option('proxmox_ssh_user') != 'root': qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd try: chan = self.ssh.get_transport().open_session() - chan.exec_command(' '.join(qm_cmd)) - + command = ' '.join(qm_cmd) + chan.exec_command(command) + if data_in: chan.sendall(data_in) chan.shutdown_write() - + stdout = b''.join(chan.makefile('rb', 4096)) stderr = b''.join(chan.makefile_stderr('rb', 4096)) returncode = chan.recv_exit_status() - + if returncode != 0: raise AnsibleError(f'qm command failed: {stderr.decode()}') - + if not stdout: return None - + stdout_json = json.loads(stdout.decode()) - + if stdout_json.get('exitcode') != 0 or stdout_json.get('exited') != 1: raise AnsibleError(f'VM command failed: {stdout_json}') - + return stdout_json.get('out-data') - + except Exception as e: raise AnsibleError(f'qm execution failed: {to_text(e)}') @@ -612,16 +626,16 @@ def _check_guest_agent(self) -> None: """Check if guest agent is available""" try: qm_cmd = ['/usr/sbin/qm', 'guest', 'cmd', str(self.get_option('vmid')), 'ping'] - if self.get_option('remote_user') != 'root': + if self.get_option('proxmox_ssh_user') != 'root': qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd - + chan = self.ssh.get_transport().open_session() chan.exec_command(' '.join(qm_cmd)) returncode = chan.recv_exit_status() - + if returncode != 0: raise AnsibleError('Guest agent is not installed or not responding') - + except Exception as e: raise AnsibleError(f'Guest agent check failed: {to_text(e)}') @@ -630,7 +644,7 @@ def _check_required_commands(self) -> None: required_commands = ["cat", "dd", "stat", "base64", "sha256sum"] for cmd in required_commands: try: - result = self._qm_exec(['sh', '-c', f'which {cmd}']) + result = self._qm_exec(['sh', '-c', f"'which {cmd}'"]) if not result: raise AnsibleError(f"Command '{cmd}' is not available on the VM") except Exception: @@ -722,59 +736,73 @@ def exec_command(self, cmd: str, in_data: bytes | None = None, sudoable: bool = if 'qm: not found' in stderr.decode('utf-8'): raise AnsibleError(f'qm not found in path of host: {to_text(self.get_option("remote_addr"))}') + # Check proxmox qm binary return code: + if returncode == 0: + # Parse results of command executed inside of the vm + stdout_json = json.loads(stdout.decode()) + # Check if command inside of vm failed + if stdout_json.get('exitcode') != 0 or stdout_json.get('exited') != 1: + raise AnsibleError(f'VM command failed: {stdout_json}') + returncode = stdout_json.get('exitcode') + # Extract output from command executed inside of vm + if stdout_json.get('out-data'): + stdout = stdout_json.get('out-data').encode() + else: + stdout = b'' + return (returncode, no_prompt_out + stdout, no_prompt_out + stderr) def put_file(self, in_path: str, out_path: str) -> None: """ transfer a file from local to VM using chunked transfer """ display.vvv(f'PUT {in_path} TO {out_path}', host=self.get_option('remote_addr')) - + try: # Check guest agent and required commands self._check_guest_agent() self._check_required_commands() - + file_size = os.path.getsize(in_path) chunk_size = self.get_option('qm_file_chunk_size_put') total_chunks = (file_size + chunk_size - 1) // chunk_size - + display.vvv(f'File size: {file_size} bytes. Transferring in {total_chunks} chunks.') - + operator = '>' - + with open(in_path, 'rb') as f: for chunk_num in range(total_chunks): chunk = f.read(chunk_size) if not chunk: break - + display.vvv(f'Transferring chunk {chunk_num + 1}/{total_chunks} ({len(chunk)} bytes)') - + # Transfer chunk using qm guest exec - self._qm_exec(['sh', '-c', f'cat {operator} {out_path}'], data_in=chunk) + self._qm_exec(['sh', '-c', f"'cat {operator} {out_path}'"], data_in=chunk) operator = '>>' # After first chunk, append - + # Verify file transfer try: - remote_size = int(self._qm_exec(['sh', '-c', f'stat --printf="%s" {out_path}']) or '0') + remote_size = int(self._qm_exec(['sh', '-c', f"'stat --printf=\"%s\" {out_path}'"]) or '0') if remote_size != file_size: raise AnsibleError(f'File size mismatch: local={file_size}, remote={remote_size}') - + # Calculate checksums for verification local_hash = hashlib.sha256() with open(in_path, 'rb') as f: for chunk in iter(lambda: f.read(8192), b""): local_hash.update(chunk) local_checksum = local_hash.hexdigest() - - remote_checksum = self._qm_exec(['sh', '-c', f'sha256sum {out_path} | cut -d " " -f 1']).strip() - + + remote_checksum = self._qm_exec(['sh', '-c', f"'sha256sum {out_path} | cut -d \" \" -f 1'"]).strip() + if local_checksum != remote_checksum: raise AnsibleError(f'Checksum mismatch: local={local_checksum}, remote={remote_checksum}') - + except Exception as e: display.warning(f'File verification failed: {to_text(e)}') - + except Exception as e: raise AnsibleError(f'error occurred while putting file from {in_path} to {out_path}!\n{to_text(e)}') @@ -782,75 +810,75 @@ def fetch_file(self, in_path: str, out_path: str) -> None: """ fetch a file from VM using chunked transfer """ display.vvv(f'FETCH {in_path} TO {out_path}', host=self.get_option('remote_addr')) - + try: # Check guest agent and required commands self._check_guest_agent() self._check_required_commands() - + # Get file size - file_size = int(self._qm_exec(['sh', '-c', f'stat --printf="%s" {in_path}']) or '0') + file_size = int(self._qm_exec(['sh', '-c', f"'stat --printf=\"%s\" {in_path}'"]) or '0') if file_size == 0: raise AnsibleError(f'File {in_path} does not exist or is empty') - + chunk_size = self.get_option('qm_file_chunk_size_fetch') blocksize = 4096 count = int(chunk_size / blocksize) total_chunks = (file_size + chunk_size - 1) // chunk_size - + display.vvv(f'File size: {file_size} bytes. Fetching in {total_chunks} chunks.') - + transferred_bytes = 0 - + with open(out_path, 'wb') as f: for chunk_num in range(total_chunks): display.vvv(f'Fetching chunk {chunk_num + 1}/{total_chunks}') - + # Calculate remaining bytes to transfer remaining_bytes = file_size - transferred_bytes current_chunk_size = min(chunk_size, remaining_bytes) - + # Fetch chunk using dd + base64 - cmd = f'dd if={in_path} bs={blocksize} count={count} skip={count * chunk_num} 2>/dev/null | base64 -w0' + cmd = f"'dd if={in_path} bs={blocksize} count={count} skip={count * chunk_num} 2>/dev/null | base64 -w0'" chunk_data_b64 = self._qm_exec(['sh', '-c', cmd]) - + if not chunk_data_b64: break - + # Decode base64 data chunk_data = base64.standard_b64decode(chunk_data_b64) - + # Trim chunk to actual remaining file size if len(chunk_data) > remaining_bytes: chunk_data = chunk_data[:remaining_bytes] - + f.write(chunk_data) transferred_bytes += len(chunk_data) - + if transferred_bytes >= file_size: break - + # Verify file transfer try: local_size = os.path.getsize(out_path) if local_size != file_size: raise AnsibleError(f'File size mismatch: local={local_size}, remote={file_size}') - + # Calculate checksums for verification local_hash = hashlib.sha256() with open(out_path, 'rb') as f: for chunk in iter(lambda: f.read(8192), b""): local_hash.update(chunk) local_checksum = local_hash.hexdigest() - - remote_checksum = self._qm_exec(['sh', '-c', f'sha256sum {in_path} | cut -d " " -f 1']).strip() - + + remote_checksum = self._qm_exec(['sh', '-c', f"'sha256sum {in_path} | cut -d \" \" -f 1'"]).strip() + if local_checksum != remote_checksum: raise AnsibleError(f'Checksum mismatch: local={local_checksum}, remote={remote_checksum}') - + except Exception as e: display.warning(f'File verification failed: {to_text(e)}') - + except Exception as e: raise AnsibleError(f'error occurred while fetching file from {in_path} to {out_path}!\n{to_text(e)}') From 9b1fb549ddf8099aab065d75e78e8702ce52a0aa Mon Sep 17 00:00:00 2001 From: Nils Stein <31704359+mietzen@users.noreply.github.com> Date: Sun, 14 Sep 2025 18:07:13 +0200 Subject: [PATCH 03/10] refactoring --- plugins/connection/proxmox_qm_remote.py | 348 ++++++++++++++---------- 1 file changed, 203 insertions(+), 145 deletions(-) diff --git a/plugins/connection/proxmox_qm_remote.py b/plugins/connection/proxmox_qm_remote.py index 5bec5ea6..e3268af7 100644 --- a/plugins/connection/proxmox_qm_remote.py +++ b/plugins/connection/proxmox_qm_remote.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Derived from ansible/plugins/connection/paramiko_ssh.py (c) 2012, Michael DeHaan -# Copyright (c) 2024 Nils Stein (@mietzen) -# Copyright (c) 2024 Ansible Project +# Copyright (c) 2025 Nils Stein (@mietzen) +# Copyright (c) 2025 Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later @@ -289,42 +289,66 @@ # -------------------------------- # all: # children: -# vms: +# qemu: # hosts: # vm-1: # ansible_host: 10.0.0.10 # proxmox_vmid: 100 # ansible_connection: community.proxmox.proxmox_qm_remote -# ansible_user: ansible +# ansible_user: root +# proxmox_ssh_user: ansible # vm-2: # ansible_host: 10.0.0.10 # proxmox_vmid: 200 # ansible_connection: community.proxmox.proxmox_qm_remote -# ansible_user: ansible +# ansible_user: root +# proxmox_ssh_user: ansible # proxmox: # hosts: # proxmox-1: # ansible_host: 10.0.0.10 # +# --------------------------------------------- +# Dynamic inventory file: inventory.proxmox.yml +# --------------------------------------------- +# plugin: community.proxmox.proxmox +# url: https://10.0.0.10:8006 +# validate_certs: false +# user: ansible@pam +# token_id: ansible +# token_secret: !vault | +# $ANSIBLE_VAULT;1.1;AES256 +# ... +# +# want_facts: true +# exclude_nodes: true +# filters: +# - proxmox_vmtype == "qemu" +# - proxmox_status == "running" +# - proxmox_agent == "1" +# want_proxmox_nodes_ansible_host: false +# compose: +# ansible_host: "'10.0.0.10'" +# ansible_connection: "'community.proxmox.proxmox_qm_remote'" +# proxmox_ssh_user: "'ansible'" +# ansible_user: "'root'" +# # ---------------------- # Playbook: playbook.yml # ---------------------- --- -- hosts: vms +- hosts: qemu + # On nodes with many containers you might want to deactivate the devices facts + # or set `gather_facts: false` if you don't need them. + # More info on gathering fact subsets: + # https://docs.ansible.com/ansible/latest/collections/ansible/builtin/setup_module.html + # + # gather_facts: true + # gather_subset: + # - "!devices" tasks: - - name: Ping VM + - name: Ping Proxmox VM ansible.builtin.ping: - - - name: Copy file to VM - ansible.builtin.copy: - src: ./local_file.txt - dest: /tmp/remote_file.txt - - - name: Fetch file from VM - ansible.builtin.fetch: - src: /tmp/remote_file.txt - dest: ./fetched_file.txt - flat: yes """ import base64 @@ -350,11 +374,6 @@ from ansible.utils.path import makedirs_safe from binascii import hexlify -import os -if os.getenv("ANSIBLE_DEBUGPY") == "1": - import debugpy - debugpy.listen(("0.0.0.0", 5678)) - debugpy.wait_for_client() try: import paramiko @@ -397,10 +416,12 @@ def missing_host_key(self, client, hostname, key) -> None: ktype = key.get_name() if self.connection.get_option('use_persistent_connections') or self.connection.force_persistence: - raise AnsibleError(authenticity_msg(hostname, ktype, fingerprint)[1:92]) + raise AnsibleError(authenticity_msg( + hostname, ktype, fingerprint)[1:92]) inp = to_text( - display.prompt_until(authenticity_msg(hostname, ktype, fingerprint), private=False), + display.prompt_until(authenticity_msg( + hostname, ktype, fingerprint), private=False), errors='surrogate_or_strict' ) @@ -418,7 +439,8 @@ class Connection(ConnectionBase): _log_channel: str | None = None def __init__(self, play_context, new_stdin, *args, **kwargs): - super(Connection, self).__init__(play_context, new_stdin, *args, **kwargs) + super(Connection, self).__init__( + play_context, new_stdin, *args, **kwargs) def _set_log_channel(self, name: str) -> None: """ Mimic paramiko.SSHClient.set_log_channel """ @@ -438,7 +460,8 @@ def _parse_proxy_command(self, port: int = 22) -> dict[str, t.Any]: proxy_command = proxy_command.replace(find, str(replace)) try: sock_kwarg = {'sock': paramiko.ProxyCommand(proxy_command)} - display.vvv(f'CONFIGURE PROXY COMMAND FOR CONNECTION: {proxy_command}', host=self.get_option('remote_addr')) + display.vvv(f'CONFIGURE PROXY COMMAND FOR CONNECTION: {proxy_command}', host=self.get_option( + 'remote_addr')) except AttributeError: display.warning('Paramiko ProxyCommand support unavailable. ' 'Please upgrade to Paramiko 1.9.0 or newer. ' @@ -450,24 +473,34 @@ def _connect(self) -> Connection: """ activates the connection object """ if PARAMIKO_IMPORT_ERR is not None: - raise AnsibleError(f'paramiko is not installed: {to_native(PARAMIKO_IMPORT_ERR)}') + raise AnsibleError( + f'paramiko is not installed: {to_native(PARAMIKO_IMPORT_ERR)}') port = self.get_option('port') display.vvv(f'ESTABLISH PARAMIKO SSH CONNECTION FOR USER: {self.get_option("remote_user")} on PORT {to_text(port)} TO {self.get_option("remote_addr")}', host=self.get_option('remote_addr')) + if self.get_option('proxmox_ssh_user') != 'root': + display.vvv(f'INFO Running as non root user: {self.get_option("proxmox_ssh_user")}, trying to run qm with become method: ' + + f'{self.get_option("proxmox_become_method")}', + host=self.get_option('remote_addr')) + ssh = paramiko.SSHClient() # Set pubkey and hostkey algorithms - paramiko_preferred_pubkeys = getattr(paramiko.Transport, '_preferred_pubkeys', ()) - paramiko_preferred_hostkeys = getattr(paramiko.Transport, '_preferred_keys', ()) + paramiko_preferred_pubkeys = getattr( + paramiko.Transport, '_preferred_pubkeys', ()) + paramiko_preferred_hostkeys = getattr( + paramiko.Transport, '_preferred_keys', ()) use_rsa_sha2_algorithms = self.get_option('use_rsa_sha2_algorithms') disabled_algorithms: t.Dict[str, t.Iterable[str]] = {} if not use_rsa_sha2_algorithms: if paramiko_preferred_pubkeys: - disabled_algorithms['pubkeys'] = tuple(a for a in paramiko_preferred_pubkeys if 'rsa-sha2' in a) + disabled_algorithms['pubkeys'] = tuple( + a for a in paramiko_preferred_pubkeys if 'rsa-sha2' in a) if paramiko_preferred_hostkeys: - disabled_algorithms['keys'] = tuple(a for a in paramiko_preferred_hostkeys if 'rsa-sha2' in a) + disabled_algorithms['keys'] = tuple( + a for a in paramiko_preferred_hostkeys if 'rsa-sha2' in a) if self._log_channel is not None: ssh.set_log_channel(self._log_channel) @@ -482,11 +515,13 @@ def _connect(self) -> Connection: except IOError: pass except paramiko.hostkeys.InvalidHostKey as e: - raise AnsibleConnectionFailure(f'Invalid host key: {to_text(e.line)}') + raise AnsibleConnectionFailure( + f'Invalid host key: {to_text(e.line)}') try: ssh.load_system_host_keys() except paramiko.hostkeys.InvalidHostKey as e: - raise AnsibleConnectionFailure(f'Invalid host key: {to_text(e.line)}') + raise AnsibleConnectionFailure( + f'Invalid host key: {to_text(e.line)}') ssh_connect_kwargs = self._parse_proxy_command(port) ssh.set_missing_host_key_policy(MyAddPolicy(self)) @@ -499,13 +534,15 @@ def _connect(self) -> Connection: try: key_filename = None if self.get_option('private_key_file'): - key_filename = os.path.expanduser(self.get_option('private_key_file')) + key_filename = os.path.expanduser( + self.get_option('private_key_file')) if LooseVersion(paramiko.__version__) >= LooseVersion('2.2.0'): ssh_connect_kwargs['auth_timeout'] = self.get_option('timeout') if LooseVersion(paramiko.__version__) >= LooseVersion('1.15.0'): - ssh_connect_kwargs['banner_timeout'] = self.get_option('banner_timeout') + ssh_connect_kwargs['banner_timeout'] = self.get_option( + 'banner_timeout') ssh.connect( self.get_option('remote_addr').lower(), @@ -520,14 +557,16 @@ def _connect(self) -> Connection: **ssh_connect_kwargs, ) except paramiko.ssh_exception.BadHostKeyException as e: - raise AnsibleConnectionFailure(f'host key mismatch for {to_text(e.hostname)}') + raise AnsibleConnectionFailure( + f'host key mismatch for {to_text(e.hostname)}') except paramiko.ssh_exception.AuthenticationException as e: msg = f'Failed to authenticate: {e}' raise AnsibleAuthenticationFailure(msg) except Exception as e: msg = to_text(e) if u'PID check failed' in msg: - raise AnsibleError('paramiko version issue, please upgrade paramiko on the machine running ansible') + raise AnsibleError( + 'paramiko version issue, please upgrade paramiko on the machine running ansible') elif u'Private key file is encrypted' in msg: msg = f'ssh {self.get_option("remote_user")}@{self.get_option("remote_addr")}:{port} : ' + \ f'{msg}\nTo connect as a different user, use -u .' @@ -542,7 +581,8 @@ def _connect(self) -> Connection: def _any_keys_added(self) -> bool: for hostname, keys in self.ssh._host_keys.items(): for keytype, key in keys.items(): - added_this_time = getattr(key, '_added_by_ansible_this_time', False) + added_this_time = getattr( + key, '_added_by_ansible_this_time', False) if added_this_time: return True return False @@ -558,83 +598,88 @@ def _save_ssh_host_keys(self, filename: str) -> None: with open(filename, 'w') as f: for hostname, keys in self.ssh._host_keys.items(): for keytype, key in keys.items(): - added_this_time = getattr(key, '_added_by_ansible_this_time', False) + added_this_time = getattr( + key, '_added_by_ansible_this_time', False) if not added_this_time: f.write(f'{hostname} {keytype} {key.get_base64()}\n') for hostname, keys in self.ssh._host_keys.items(): for keytype, key in keys.items(): - added_this_time = getattr(key, '_added_by_ansible_this_time', False) + added_this_time = getattr( + key, '_added_by_ansible_this_time', False) if added_this_time: f.write(f'{hostname} {keytype} {key.get_base64()}\n') - def _build_qm_command(self, cmd: str) -> str: - """Build qm guest exec command""" - qm_cmd = ['/usr/sbin/qm', 'guest', 'exec', str(self.get_option('vmid')), '--', cmd] - if self.get_option('proxmox_ssh_user') != 'root': - qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd - display.vvv(f'INFO Running as non root user: {self.get_option("proxmox_ssh_user")}, trying to run qm with become method: ' + - f'{self.get_option("proxmox_become_method")}', - host=self.get_option('remote_addr')) - return ' '.join(qm_cmd) - - def _qm_exec(self, cmd: list[str], data_in: bytes | None = None, timeout: int | None = None) -> str | None: - """Execute command inside VM via qm guest exec and return output""" + def _build_qm_command(self, cmd_args: list[str], timeout: int | None = None, pass_stdin: bool = False) -> list[str]: + """Build qm guest exec command as list - base implementation""" if timeout is None: timeout = self.get_option('qm_timeout') - qm_cmd = ['/usr/sbin/qm', 'guest', 'exec', str(self.get_option('vmid'))] + qm_cmd = ['/usr/sbin/qm', 'guest', + 'exec', str(self.get_option('vmid'))] - if data_in: + if pass_stdin: qm_cmd += ['--pass-stdin', '1'] - qm_cmd += ['--timeout', str(timeout), '--'] + cmd + qm_cmd += ['--timeout', str(timeout), '--'] + cmd_args if self.get_option('proxmox_ssh_user') != 'root': qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd + return qm_cmd + + def _execute_ssh_command(self, cmd: list[str], data_in: bytes | None = None, bufsize: int = 4096) -> tuple[int, bytes, bytes]: + """Execute SSH command and return (returncode, stdout, stderr)""" try: chan = self.ssh.get_transport().open_session() - command = ' '.join(qm_cmd) + command = ' '.join(cmd) chan.exec_command(command) if data_in: chan.sendall(data_in) chan.shutdown_write() - stdout = b''.join(chan.makefile('rb', 4096)) - stderr = b''.join(chan.makefile_stderr('rb', 4096)) + stdout = b''.join(chan.makefile('rb', bufsize)) + stderr = b''.join(chan.makefile_stderr('rb', bufsize)) returncode = chan.recv_exit_status() - if returncode != 0: - raise AnsibleError(f'qm command failed: {stderr.decode()}') + return returncode, stdout, stderr - if not stdout: - return None + except Exception as e: + raise AnsibleError(f'SSH command execution failed: {to_text(e)}') - stdout_json = json.loads(stdout.decode()) + def _qm_exec(self, cmd: list[str], data_in: bytes | None = None, timeout: int | None = None) -> str | None: + """Execute command inside VM via qm guest exec and return output""" + qm_cmd = self._build_qm_command(cmd, timeout, bool(data_in)) - if stdout_json.get('exitcode') != 0 or stdout_json.get('exited') != 1: - raise AnsibleError(f'VM command failed: {stdout_json}') + returncode, stdout, stderr = self._execute_ssh_command(qm_cmd, data_in) - return stdout_json.get('out-data') + if returncode != 0: + raise AnsibleError(f'qm command failed: {stderr.decode()}') - except Exception as e: - raise AnsibleError(f'qm execution failed: {to_text(e)}') + if not stdout: + return None + + stdout_json = json.loads(stdout.decode()) + + if stdout_json.get('exitcode') != 0 or stdout_json.get('exited') != 1: + raise AnsibleError(f'VM command failed: {stdout_json}') + + return stdout_json.get('out-data') def _check_guest_agent(self) -> None: """Check if guest agent is available""" try: - qm_cmd = ['/usr/sbin/qm', 'guest', 'cmd', str(self.get_option('vmid')), 'ping'] + qm_cmd = ['/usr/sbin/qm', 'guest', 'cmd', + str(self.get_option('vmid')), 'ping'] if self.get_option('proxmox_ssh_user') != 'root': qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd - chan = self.ssh.get_transport().open_session() - chan.exec_command(' '.join(qm_cmd)) - returncode = chan.recv_exit_status() + returncode, _, _ = self._execute_ssh_command(qm_cmd) if returncode != 0: - raise AnsibleError('Guest agent is not installed or not responding') + raise AnsibleError( + 'Guest agent is not installed or not responding') except Exception as e: raise AnsibleError(f'Guest agent check failed: {to_text(e)}') @@ -646,16 +691,46 @@ def _check_required_commands(self) -> None: try: result = self._qm_exec(['sh', '-c', f"'which {cmd}'"]) if not result: - raise AnsibleError(f"Command '{cmd}' is not available on the VM") + raise AnsibleError( + f"Command '{cmd}' is not available on the VM") except Exception: - raise AnsibleError(f"Command '{cmd}' is not available on the VM") + raise AnsibleError( + f"Command '{cmd}' is not available on the VM") + + def _verify_file_transfer(self, local_path: str, remote_path: str, expected_size: int) -> None: + """Verify file transfer by comparing size and checksum""" + try: + # Verify size + remote_size = int(self._qm_exec( + ['sh', '-c', f"'stat --printf=\"%s\" {remote_path}'"]) or '0') + if remote_size != expected_size: + raise AnsibleError( + f'File size mismatch: expected={expected_size}, remote={remote_size}') + + # Calculate and compare checksums + local_hash = hashlib.sha256() + with open(local_path, 'rb') as f: + for chunk in iter(lambda: f.read(8192), b""): + local_hash.update(chunk) + local_checksum = local_hash.hexdigest() + + remote_checksum = self._qm_exec( + ['sh', '-c', f"'sha256sum {remote_path} | cut -d \" \" -f 1'"]).strip() + + if local_checksum != remote_checksum: + raise AnsibleError( + f'Checksum mismatch: local={local_checksum}, remote={remote_checksum}') + + except Exception as e: + display.warning(f'File verification failed: {to_text(e)}') def exec_command(self, cmd: str, in_data: bytes | None = None, sudoable: bool = True) -> tuple[int, bytes, bytes]: """ run a command inside the VM """ - cmd = self._build_qm_command(cmd) + cmd = ' '.join(self._build_qm_command([cmd])) - super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable) + super(Connection, self).exec_command( + cmd, in_data=in_data, sudoable=sudoable) bufsize = 4096 @@ -670,7 +745,8 @@ def exec_command(self, cmd: str, in_data: bytes | None = None, sudoable: bool = raise AnsibleConnectionFailure(to_native(msg)) if self.get_option('pty') and sudoable: - chan.get_pty(term=os.getenv('TERM', 'vt100'), width=int(os.getenv('COLUMNS', 0)), height=int(os.getenv('LINES', 0))) + chan.get_pty(term=os.getenv('TERM', 'vt100'), width=int( + os.getenv('COLUMNS', 0)), height=int(os.getenv('LINES', 0))) display.vvv(f'EXEC {cmd}', host=self.get_option('remote_addr')) @@ -695,8 +771,10 @@ def exec_command(self, cmd: str, in_data: bytes | None = None, sudoable: bool = display.debug(f'chunk is: {to_text(chunk)}') if not chunk: if b'unknown user' in become_output: - n_become_user = to_native(self.become.get_option('become_user')) - raise AnsibleError(f'user {n_become_user} does not exist') + n_become_user = to_native( + self.become.get_option('become_user')) + raise AnsibleError( + f'user {n_become_user} does not exist') else: break become_output += chunk @@ -712,9 +790,11 @@ def exec_command(self, cmd: str, in_data: bytes | None = None, sudoable: bool = if password_prompt: if self.become: become_pass = self.become.get_option('become_pass') - chan.sendall(to_bytes(become_pass, errors='surrogate_or_strict') + b'\n') + chan.sendall( + to_bytes(become_pass, errors='surrogate_or_strict') + b'\n') else: - raise AnsibleError('A password is required but none was supplied') + raise AnsibleError( + 'A password is required but none was supplied') else: no_prompt_out += become_output no_prompt_err += become_output @@ -727,35 +807,38 @@ def exec_command(self, cmd: str, in_data: bytes | None = None, sudoable: bool = chan.shutdown_write() except socket.timeout: - raise AnsibleError('ssh timed out waiting for privilege escalation.\n' + to_text(become_output)) + raise AnsibleError( + 'ssh timed out waiting for privilege escalation.\n' + to_text(become_output)) stdout = b''.join(chan.makefile('rb', bufsize)) stderr = b''.join(chan.makefile_stderr('rb', bufsize)) returncode = chan.recv_exit_status() if 'qm: not found' in stderr.decode('utf-8'): - raise AnsibleError(f'qm not found in path of host: {to_text(self.get_option("remote_addr"))}') + raise AnsibleError( + f'qm not found in path of host: {to_text(self.get_option("remote_addr"))}') # Check proxmox qm binary return code: if returncode == 0: - # Parse results of command executed inside of the vm - stdout_json = json.loads(stdout.decode()) - # Check if command inside of vm failed - if stdout_json.get('exitcode') != 0 or stdout_json.get('exited') != 1: + # Parse results of command executed inside of the vm + stdout_json = json.loads(stdout.decode()) + # Check if command inside of vm failed + if stdout_json.get('exitcode') != 0 or stdout_json.get('exited') != 1: raise AnsibleError(f'VM command failed: {stdout_json}') - returncode = stdout_json.get('exitcode') - # Extract output from command executed inside of vm - if stdout_json.get('out-data'): - stdout = stdout_json.get('out-data').encode() - else: - stdout = b'' + returncode = stdout_json.get('exitcode') + # Extract output from command executed inside of vm + if stdout_json.get('out-data'): + stdout = stdout_json.get('out-data').encode() + else: + stdout = b'' return (returncode, no_prompt_out + stdout, no_prompt_out + stderr) def put_file(self, in_path: str, out_path: str) -> None: """ transfer a file from local to VM using chunked transfer """ - display.vvv(f'PUT {in_path} TO {out_path}', host=self.get_option('remote_addr')) + display.vvv(f'PUT {in_path} TO {out_path}', + host=self.get_option('remote_addr')) try: # Check guest agent and required commands @@ -766,7 +849,8 @@ def put_file(self, in_path: str, out_path: str) -> None: chunk_size = self.get_option('qm_file_chunk_size_put') total_chunks = (file_size + chunk_size - 1) // chunk_size - display.vvv(f'File size: {file_size} bytes. Transferring in {total_chunks} chunks.') + display.vvv( + f'File size: {file_size} bytes. Transferring in {total_chunks} chunks.') operator = '>' @@ -776,40 +860,26 @@ def put_file(self, in_path: str, out_path: str) -> None: if not chunk: break - display.vvv(f'Transferring chunk {chunk_num + 1}/{total_chunks} ({len(chunk)} bytes)') + display.vvv( + f'Transferring chunk {chunk_num + 1}/{total_chunks} ({len(chunk)} bytes)') # Transfer chunk using qm guest exec - self._qm_exec(['sh', '-c', f"'cat {operator} {out_path}'"], data_in=chunk) + self._qm_exec( + ['sh', '-c', f"'cat {operator} {out_path}'"], data_in=chunk) operator = '>>' # After first chunk, append # Verify file transfer - try: - remote_size = int(self._qm_exec(['sh', '-c', f"'stat --printf=\"%s\" {out_path}'"]) or '0') - if remote_size != file_size: - raise AnsibleError(f'File size mismatch: local={file_size}, remote={remote_size}') - - # Calculate checksums for verification - local_hash = hashlib.sha256() - with open(in_path, 'rb') as f: - for chunk in iter(lambda: f.read(8192), b""): - local_hash.update(chunk) - local_checksum = local_hash.hexdigest() - - remote_checksum = self._qm_exec(['sh', '-c', f"'sha256sum {out_path} | cut -d \" \" -f 1'"]).strip() - - if local_checksum != remote_checksum: - raise AnsibleError(f'Checksum mismatch: local={local_checksum}, remote={remote_checksum}') - - except Exception as e: - display.warning(f'File verification failed: {to_text(e)}') + self._verify_file_transfer(in_path, out_path, file_size) except Exception as e: - raise AnsibleError(f'error occurred while putting file from {in_path} to {out_path}!\n{to_text(e)}') + raise AnsibleError( + f'error occurred while putting file from {in_path} to {out_path}!\n{to_text(e)}') def fetch_file(self, in_path: str, out_path: str) -> None: """ fetch a file from VM using chunked transfer """ - display.vvv(f'FETCH {in_path} TO {out_path}', host=self.get_option('remote_addr')) + display.vvv(f'FETCH {in_path} TO {out_path}', + host=self.get_option('remote_addr')) try: # Check guest agent and required commands @@ -817,22 +887,26 @@ def fetch_file(self, in_path: str, out_path: str) -> None: self._check_required_commands() # Get file size - file_size = int(self._qm_exec(['sh', '-c', f"'stat --printf=\"%s\" {in_path}'"]) or '0') + file_size = int(self._qm_exec( + ['sh', '-c', f"'stat --printf=\"%s\" {in_path}'"]) or '0') if file_size == 0: - raise AnsibleError(f'File {in_path} does not exist or is empty') + raise AnsibleError( + f'File {in_path} does not exist or is empty') chunk_size = self.get_option('qm_file_chunk_size_fetch') blocksize = 4096 count = int(chunk_size / blocksize) total_chunks = (file_size + chunk_size - 1) // chunk_size - display.vvv(f'File size: {file_size} bytes. Fetching in {total_chunks} chunks.') + display.vvv( + f'File size: {file_size} bytes. Fetching in {total_chunks} chunks.') transferred_bytes = 0 with open(out_path, 'wb') as f: for chunk_num in range(total_chunks): - display.vvv(f'Fetching chunk {chunk_num + 1}/{total_chunks}') + display.vvv( + f'Fetching chunk {chunk_num + 1}/{total_chunks}') # Calculate remaining bytes to transfer remaining_bytes = file_size - transferred_bytes @@ -859,28 +933,11 @@ def fetch_file(self, in_path: str, out_path: str) -> None: break # Verify file transfer - try: - local_size = os.path.getsize(out_path) - if local_size != file_size: - raise AnsibleError(f'File size mismatch: local={local_size}, remote={file_size}') - - # Calculate checksums for verification - local_hash = hashlib.sha256() - with open(out_path, 'rb') as f: - for chunk in iter(lambda: f.read(8192), b""): - local_hash.update(chunk) - local_checksum = local_hash.hexdigest() - - remote_checksum = self._qm_exec(['sh', '-c', f"'sha256sum {in_path} | cut -d \" \" -f 1'"]).strip() - - if local_checksum != remote_checksum: - raise AnsibleError(f'Checksum mismatch: local={local_checksum}, remote={remote_checksum}') - - except Exception as e: - display.warning(f'File verification failed: {to_text(e)}') + self._verify_file_transfer(out_path, in_path, file_size) except Exception as e: - raise AnsibleError(f'error occurred while fetching file from {in_path} to {out_path}!\n{to_text(e)}') + raise AnsibleError( + f'error occurred while fetching file from {in_path} to {out_path}!\n{to_text(e)}') def reset(self) -> None: """ reset the connection """ @@ -926,7 +983,8 @@ def close(self) -> None: except paramiko.hostkeys.InvalidHostKey as e: raise AnsibleConnectionFailure(f'Invalid host key: {e.line}') except Exception as e: - raise AnsibleError(f'error occurred while writing SSH host keys!\n{to_text(e)}') + raise AnsibleError( + f'error occurred while writing SSH host keys!\n{to_text(e)}') finally: if tmp_keyfile_name is not None: pathlib.Path(tmp_keyfile_name).unlink(missing_ok=True) From 37b7be6b36e3e4d1ea9234913d27db319cc88313 Mon Sep 17 00:00:00 2001 From: Nils Stein <31704359+mietzen@users.noreply.github.com> Date: Sun, 14 Sep 2025 18:16:04 +0200 Subject: [PATCH 04/10] Copied Tests from remote_pct --- .../connection_proxmox_qm_remote/aliases | 12 + .../dependencies.yml | 18 + .../connection_proxmox_qm_remote/files/qm | 33 + .../plugin-specific-tests.yml | 32 + .../connection_proxmox_qm_remote/runme.sh | 19 + .../connection_proxmox_qm_remote/test.sh | 1 + .../test_connection.inventory | 14 + .../connection/test_proxmox_qm_remote.py | 606 ++++++++++++++++++ 8 files changed, 735 insertions(+) create mode 100644 tests/integration/targets/connection_proxmox_qm_remote/aliases create mode 100644 tests/integration/targets/connection_proxmox_qm_remote/dependencies.yml create mode 100755 tests/integration/targets/connection_proxmox_qm_remote/files/qm create mode 100644 tests/integration/targets/connection_proxmox_qm_remote/plugin-specific-tests.yml create mode 100755 tests/integration/targets/connection_proxmox_qm_remote/runme.sh create mode 120000 tests/integration/targets/connection_proxmox_qm_remote/test.sh create mode 100644 tests/integration/targets/connection_proxmox_qm_remote/test_connection.inventory create mode 100644 tests/unit/plugins/connection/test_proxmox_qm_remote.py diff --git a/tests/integration/targets/connection_proxmox_qm_remote/aliases b/tests/integration/targets/connection_proxmox_qm_remote/aliases new file mode 100644 index 00000000..d2fefd10 --- /dev/null +++ b/tests/integration/targets/connection_proxmox_qm_remote/aliases @@ -0,0 +1,12 @@ +# Copyright (c) 2025 Nils Stein (@mietzen) +# Copyright (c) 2025 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/3 +destructive +needs/root +needs/target/connection +skip/docker +skip/alpine +skip/macos diff --git a/tests/integration/targets/connection_proxmox_qm_remote/dependencies.yml b/tests/integration/targets/connection_proxmox_qm_remote/dependencies.yml new file mode 100644 index 00000000..fad18a1b --- /dev/null +++ b/tests/integration/targets/connection_proxmox_qm_remote/dependencies.yml @@ -0,0 +1,18 @@ +--- +# Copyright (c) 2025 Nils Stein (@mietzen) +# Copyright (c) 2025 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- hosts: localhost + gather_facts: true + serial: 1 + tasks: + - name: Copy qm mock + copy: + src: files/qm + dest: /usr/sbin/qm + mode: '0755' + - name: Install paramiko + pip: + name: "paramiko>=3.0.0" diff --git a/tests/integration/targets/connection_proxmox_qm_remote/files/qm b/tests/integration/targets/connection_proxmox_qm_remote/files/qm new file mode 100755 index 00000000..cc110d73 --- /dev/null +++ b/tests/integration/targets/connection_proxmox_qm_remote/files/qm @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Copyright (c) 2025 Nils Stein (@mietzen) +# Copyright (c) 2025 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Shell script to mock proxmox qm behaviour + +>&2 echo "[DEBUG] INPUT: $@" + +pwd="$(pwd)" + +# Get quoted parts and restore quotes +declare -a cmd=() +for arg in "$@"; do + if [[ $arg =~ [[:space:]] ]]; then + arg="'$arg'" + fi + cmd+=("$arg") +done + +cmd="${cmd[@]:3}" +vmid="${@:2:1}" +>&2 echo "[INFO] MOCKING: qm ${@:1:3} ${cmd}" +tmp_dir="/tmp/ansible-remote/proxmox_qm_remote/integration_test/vm_${vmid}" +mkdir -p "$tmp_dir" +>&2 echo "[INFO] PWD: $tmp_dir" +>&2 echo "[INFO] CMD: ${cmd}" +cd "$tmp_dir" + +eval "${cmd}" + +cd "$pwd" diff --git a/tests/integration/targets/connection_proxmox_qm_remote/plugin-specific-tests.yml b/tests/integration/targets/connection_proxmox_qm_remote/plugin-specific-tests.yml new file mode 100644 index 00000000..41fe06cd --- /dev/null +++ b/tests/integration/targets/connection_proxmox_qm_remote/plugin-specific-tests.yml @@ -0,0 +1,32 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- hosts: "{{ target_hosts }}" + gather_facts: false + serial: 1 + tasks: + - name: create file without content + copy: + content: "" + dest: "{{ remote_tmp }}/test_empty.txt" + force: no + mode: '0644' + + - name: assert file without content exists + stat: + path: "{{ remote_tmp }}/test_empty.txt" + register: empty_file_stat + + - name: verify file without content exists + assert: + that: + - empty_file_stat.stat.exists + fail_msg: "The file {{ remote_tmp }}/test_empty.txt does not exist." + + - name: verify file without content is empty + assert: + that: + - empty_file_stat.stat.size == 0 + fail_msg: "The file {{ remote_tmp }}/test_empty.txt is not empty." diff --git a/tests/integration/targets/connection_proxmox_qm_remote/runme.sh b/tests/integration/targets/connection_proxmox_qm_remote/runme.sh new file mode 100755 index 00000000..a93b5afb --- /dev/null +++ b/tests/integration/targets/connection_proxmox_qm_remote/runme.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Copyright (c) 2025 Nils Stein (@mietzen) +# Copyright (c) 2025 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +set -eux + +ANSIBLE_ROLES_PATH=../ \ + ansible-playbook dependencies.yml -v "$@" + +./test.sh "$@" + +ansible-playbook plugin-specific-tests.yml -i "./test_connection.inventory" \ + -e target_hosts="proxmox_qm_remote" \ + -e action_prefix= \ + -e local_tmp=/tmp/ansible-local \ + -e remote_tmp=/tmp/ansible-remote \ + "$@" diff --git a/tests/integration/targets/connection_proxmox_qm_remote/test.sh b/tests/integration/targets/connection_proxmox_qm_remote/test.sh new file mode 120000 index 00000000..70aa5dbd --- /dev/null +++ b/tests/integration/targets/connection_proxmox_qm_remote/test.sh @@ -0,0 +1 @@ +../connection_posix/test.sh \ No newline at end of file diff --git a/tests/integration/targets/connection_proxmox_qm_remote/test_connection.inventory b/tests/integration/targets/connection_proxmox_qm_remote/test_connection.inventory new file mode 100644 index 00000000..d331a07c --- /dev/null +++ b/tests/integration/targets/connection_proxmox_qm_remote/test_connection.inventory @@ -0,0 +1,14 @@ +# Copyright (c) 2025 Nils Stein (@mietzen) +# Copyright (c) 2025 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +[proxmox_qm_remote] +proxmox_qm_remote-pipelining ansible_ssh_pipelining=true +proxmox_qm_remote-no-pipelining ansible_ssh_pipelining=false +[proxmox_qm_remote:vars] +ansible_host=localhost +ansible_user=root +ansible_python_interpreter="{{ ansible_playbook_python }}" +ansible_connection=community.proxmox.proxmox_qm_remote +proxmox_vmid=123 diff --git a/tests/unit/plugins/connection/test_proxmox_qm_remote.py b/tests/unit/plugins/connection/test_proxmox_qm_remote.py new file mode 100644 index 00000000..c8f94274 --- /dev/null +++ b/tests/unit/plugins/connection/test_proxmox_qm_remote.py @@ -0,0 +1,606 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Nils Stein (@mietzen) +# Copyright (c) 2024 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (annotations, absolute_import, division, print_function) +__metaclass__ = type + +import os +import pytest + +from ansible_collections.community.proxmox.plugins.connection.proxmox_qm_remote import authenticity_msg, MyAddPolicy +from ansible_collections.community.proxmox.plugins.module_utils._filelock import FileLock, LockTimeout +from ansible.errors import AnsibleError, AnsibleAuthenticationFailure, AnsibleConnectionFailure +from ansible.module_utils.common.text.converters import to_bytes +from ansible.playbook.play_context import PlayContext +from ansible.plugins.loader import connection_loader +from io import StringIO +from pathlib import Path +from unittest.mock import patch, MagicMock, mock_open + + +paramiko = pytest.importorskip('paramiko') + + +@pytest.fixture +def connection(): + play_context = PlayContext() + in_stream = StringIO() + conn = connection_loader.get('community.proxmox.proxmox_qm_remote', play_context, in_stream) + conn.set_option('remote_addr', '192.168.1.100') + conn.set_option('remote_user', 'root') + conn.set_option('password', 'password') + return conn + + +def test_connection_options(connection): + """ Test that connection options are properly set """ + assert connection.get_option('remote_addr') == '192.168.1.100' + assert connection.get_option('remote_user') == 'root' + assert connection.get_option('password') == 'password' + + +def test_authenticity_msg(): + """ Test authenticity message formatting """ + msg = authenticity_msg('test.host', 'ssh-rsa', 'AA:BB:CC:DD') + assert 'test.host' in msg + assert 'ssh-rsa' in msg + assert 'AA:BB:CC:DD' in msg + + +def test_missing_host_key(connection): + """ Test MyAddPolicy missing_host_key method """ + + client = MagicMock() + key = MagicMock() + key.get_fingerprint.return_value = b'fingerprint' + key.get_name.return_value = 'ssh-rsa' + + policy = MyAddPolicy(connection) + + connection.set_option('host_key_auto_add', True) + policy.missing_host_key(client, 'test.host', key) + assert hasattr(key, '_added_by_ansible_this_time') + + connection.set_option('host_key_auto_add', False) + connection.set_option('host_key_checking', False) + policy.missing_host_key(client, 'test.host', key) + + connection.set_option('host_key_checking', True) + connection.set_option('host_key_auto_add', False) + connection.set_option('use_persistent_connections', False) + + with patch('ansible.utils.display.Display.prompt_until', return_value='yes'): + policy.missing_host_key(client, 'test.host', key) + + with patch('ansible.utils.display.Display.prompt_until', return_value='no'): + with pytest.raises(AnsibleError, match='host connection rejected by user'): + policy.missing_host_key(client, 'test.host', key) + + +def test_set_log_channel(connection): + """ Test setting log channel """ + connection._set_log_channel('test_channel') + assert connection._log_channel == 'test_channel' + + +def test_parse_proxy_command(connection): + """ Test proxy command parsing """ + connection.set_option('proxy_command', 'ssh -W %h:%p proxy.example.com') + connection.set_option('remote_addr', 'target.example.com') + connection.set_option('remote_user', 'testuser') + + result = connection._parse_proxy_command(port=2222) + assert 'sock' in result + assert isinstance(result['sock'], paramiko.ProxyCommand) + + +@patch('paramiko.SSHClient') +def test_connect_with_rsa_sha2_disabled(mock_ssh, connection): + """ Test connection with RSA SHA2 algorithms disabled """ + connection.set_option('use_rsa_sha2_algorithms', False) + mock_client = MagicMock() + mock_ssh.return_value = mock_client + + connection._connect() + + call_kwargs = mock_client.connect.call_args[1] + assert 'disabled_algorithms' in call_kwargs + assert 'pubkeys' in call_kwargs['disabled_algorithms'] + + +@patch('paramiko.SSHClient') +def test_connect_with_bad_host_key(mock_ssh, connection): + """ Test connection with bad host key """ + mock_client = MagicMock() + mock_ssh.return_value = mock_client + mock_client.connect.side_effect = paramiko.ssh_exception.BadHostKeyException( + 'hostname', MagicMock(), MagicMock()) + + with pytest.raises(AnsibleConnectionFailure, match='host key mismatch'): + connection._connect() + + +@patch('paramiko.SSHClient') +def test_connect_with_invalid_host_key(mock_ssh, connection): + """ Test connection with bad host key """ + connection.set_option('host_key_checking', True) + mock_client = MagicMock() + mock_ssh.return_value = mock_client + mock_client.load_system_host_keys.side_effect = paramiko.hostkeys.InvalidHostKey( + "Bad Line!", Exception('Something crashed!')) + + with pytest.raises(AnsibleConnectionFailure, match="Invalid host key: Bad Line!"): + connection._connect() + + +@patch('paramiko.SSHClient') +def test_connect_success(mock_ssh, connection): + """ Test successful SSH connection establishment """ + mock_client = MagicMock() + mock_ssh.return_value = mock_client + + connection._connect() + + assert mock_client.connect.called + assert connection._connected + + +@patch('paramiko.SSHClient') +def test_connect_authentication_failure(mock_ssh, connection): + """ Test SSH connection with authentication failure """ + mock_client = MagicMock() + mock_ssh.return_value = mock_client + mock_client.connect.side_effect = paramiko.ssh_exception.AuthenticationException('Auth failed') + + with pytest.raises(AnsibleAuthenticationFailure): + connection._connect() + + +def test_any_keys_added(connection): + """ Test checking for added host keys """ + connection.ssh = MagicMock() + connection.ssh._host_keys = { + 'host1': { + 'ssh-rsa': MagicMock(_added_by_ansible_this_time=True), + 'ssh-ed25519': MagicMock(_added_by_ansible_this_time=False) + } + } + + assert connection._any_keys_added() is True + + connection.ssh._host_keys = { + 'host1': { + 'ssh-rsa': MagicMock(_added_by_ansible_this_time=False) + } + } + assert connection._any_keys_added() is False + + +@patch('os.path.exists') +@patch('os.stat') +@patch('tempfile.NamedTemporaryFile') +def test_save_ssh_host_keys(mock_tempfile, mock_stat, mock_exists, connection): + """ Test saving SSH host keys """ + mock_exists.return_value = True + mock_stat.return_value = MagicMock(st_mode=0o644, st_uid=1000, st_gid=1000) + mock_tempfile.return_value.__enter__.return_value.name = '/tmp/test_keys' + + connection.ssh = MagicMock() + connection.ssh._host_keys = { + 'host1': { + 'ssh-rsa': MagicMock( + get_base64=lambda: 'KEY1', + _added_by_ansible_this_time=True + ) + } + } + + mock_open_obj = mock_open() + with patch('builtins.open', mock_open_obj): + connection._save_ssh_host_keys('/tmp/test_keys') + + mock_open_obj().write.assert_called_with('host1 ssh-rsa KEY1\n') + + +def test_build_qm_command(connection): + """ Test qm command building with different users """ + connection.set_option('vmid', '100') + + cmd = connection._build_qm_command('/bin/sh -c "ls -la"') + assert cmd == '/usr/sbin/qm exec 100 -- /bin/sh -c "ls -la"' + + connection.set_option('remote_user', 'user') + connection.set_option('proxmox_become_method', 'sudo') + cmd = connection._build_qm_command('/bin/sh -c "ls -la"') + assert cmd == 'sudo /usr/sbin/qm exec 100 -- /bin/sh -c "ls -la"' + + +@patch('paramiko.SSHClient') +def test_exec_command_success(mock_ssh, connection): + """ Test successful command execution """ + mock_client = MagicMock() + mock_ssh.return_value = mock_client + mock_channel = MagicMock() + mock_transport = MagicMock() + + mock_client.get_transport.return_value = mock_transport + mock_transport.open_session.return_value = mock_channel + mock_channel.recv_exit_status.return_value = 0 + mock_channel.makefile.return_value = [to_bytes('stdout')] + mock_channel.makefile_stderr.return_value = [to_bytes("")] + + connection._connected = True + connection.ssh = mock_client + + returncode, stdout, stderr = connection.exec_command('ls -la') + + mock_transport.open_session.assert_called_once() + mock_channel.get_pty.assert_called_once() + mock_transport.set_keepalive.assert_called_once_with(5) + + +@patch('paramiko.SSHClient') +def test_exec_command_qm_not_found(mock_ssh, connection): + """ Test command execution when qm is not found """ + mock_client = MagicMock() + mock_ssh.return_value = mock_client + mock_channel = MagicMock() + mock_transport = MagicMock() + + mock_client.get_transport.return_value = mock_transport + mock_transport.open_session.return_value = mock_channel + mock_channel.recv_exit_status.return_value = 1 + mock_channel.makefile.return_value = [to_bytes("")] + mock_channel.makefile_stderr.return_value = [to_bytes('qm: not found')] + + connection._connected = True + connection.ssh = mock_client + + with pytest.raises(AnsibleError, match='qm not found in path of host'): + connection.exec_command('ls -la') + + +@patch('paramiko.SSHClient') +def test_exec_command_session_open_failure(mock_ssh, connection): + """ Test exec_command when session opening fails """ + mock_client = MagicMock() + mock_transport = MagicMock() + mock_transport.open_session.side_effect = Exception('Failed to open session') + mock_client.get_transport.return_value = mock_transport + + connection._connected = True + connection.ssh = mock_client + + with pytest.raises(AnsibleConnectionFailure, match='Failed to open session'): + connection.exec_command('test command') + + +@patch('paramiko.SSHClient') +def test_exec_command_with_privilege_escalation(mock_ssh, connection): + """ Test exec_command with privilege escalation """ + mock_client = MagicMock() + mock_channel = MagicMock() + mock_transport = MagicMock() + + mock_client.get_transport.return_value = mock_transport + mock_transport.open_session.return_value = mock_channel + connection._connected = True + connection.ssh = mock_client + + connection.become = MagicMock() + connection.become.expect_prompt.return_value = True + connection.become.check_success.return_value = False + connection.become.check_password_prompt.return_value = True + connection.become.get_option.return_value = 'sudo_password' + + mock_channel.recv.return_value = b'[sudo] password:' + mock_channel.recv_exit_status.return_value = 0 + mock_channel.makefile.return_value = [b""] + mock_channel.makefile_stderr.return_value = [b""] + + returncode, stdout, stderr = connection.exec_command('sudo test command') + + mock_channel.sendall.assert_called_once_with(b'sudo_password\n') + + +@patch('paramiko.SSHClient') +def test_exec_command_with_forward_agent(mock_ssh, connection): + """ Test exec_command with forward agent """ + mock_client = MagicMock() + mock_channel = MagicMock() + mock_transport = MagicMock() + + mock_client.get_transport.return_value = mock_transport + mock_transport.open_session.return_value = mock_channel + connection._connected = True + connection.ssh = mock_client + + connection.set_option('forward_agent', True) + + with patch('paramiko.agent.AgentRequestHandler') as mock_agent_handler: + connection.exec_command('test command') + mock_agent_handler.assert_called_once_with(mock_channel) + + +def test_put_file(connection): + """ Test putting a file to the remote system """ + connection.exec_command = MagicMock() + connection.exec_command.return_value = (0, b"", b"") + + with patch('builtins.open', create=True) as mock_open: + mock_open.return_value.__enter__.return_value.read.return_value = b'test content' + connection.put_file('/local/path', '/remote/path') + + connection.exec_command.assert_called_once_with("/bin/sh -c 'cat > /remote/path'", in_data=b'test content', sudoable=False) + + +@patch('paramiko.SSHClient') +def test_put_file_general_error(mock_ssh, connection): + """ Test put_file with general error """ + mock_client = MagicMock() + mock_ssh.return_value = mock_client + mock_channel = MagicMock() + mock_transport = MagicMock() + + mock_client.get_transport.return_value = mock_transport + mock_transport.open_session.return_value = mock_channel + mock_channel.recv_exit_status.return_value = 1 + mock_channel.makefile.return_value = [to_bytes("")] + mock_channel.makefile_stderr.return_value = [to_bytes('Some error')] + + connection._connected = True + connection.ssh = mock_client + + with pytest.raises(AnsibleError, match='error occurred while putting file from /remote/path to /local/path'): + connection.put_file('/remote/path', '/local/path') + + +@patch('paramiko.SSHClient') +def test_put_file_cat_not_found(mock_ssh, connection): + """ Test command execution when cat is not found """ + mock_client = MagicMock() + mock_ssh.return_value = mock_client + mock_channel = MagicMock() + mock_transport = MagicMock() + + mock_client.get_transport.return_value = mock_transport + mock_transport.open_session.return_value = mock_channel + mock_channel.recv_exit_status.return_value = 1 + mock_channel.makefile.return_value = [to_bytes("")] + mock_channel.makefile_stderr.return_value = [to_bytes('cat: not found')] + + connection._connected = True + connection.ssh = mock_client + + with pytest.raises(AnsibleError, match='cat not found in path of container:'): + connection.fetch_file('/remote/path', '/local/path') + + +def test_fetch_file(connection): + """ Test fetching a file from the remote system """ + connection.exec_command = MagicMock() + connection.exec_command.return_value = (0, b'test content', b"") + + with patch('builtins.open', create=True) as mock_open: + connection.fetch_file('/remote/path', '/local/path') + + connection.exec_command.assert_called_once_with("/bin/sh -c 'cat /remote/path'", sudoable=False) + mock_open.assert_called_with('/local/path', 'wb') + + +@patch('paramiko.SSHClient') +def test_fetch_file_general_error(mock_ssh, connection): + """ Test fetch_file with general error """ + mock_client = MagicMock() + mock_ssh.return_value = mock_client + mock_channel = MagicMock() + mock_transport = MagicMock() + + mock_client.get_transport.return_value = mock_transport + mock_transport.open_session.return_value = mock_channel + mock_channel.recv_exit_status.return_value = 1 + mock_channel.makefile.return_value = [to_bytes("")] + mock_channel.makefile_stderr.return_value = [to_bytes('Some error')] + + connection._connected = True + connection.ssh = mock_client + + with pytest.raises(AnsibleError, match='error occurred while fetching file from /remote/path to /local/path'): + connection.fetch_file('/remote/path', '/local/path') + + +@patch('paramiko.SSHClient') +def test_fetch_file_cat_not_found(mock_ssh, connection): + """ Test command execution when cat is not found """ + mock_client = MagicMock() + mock_ssh.return_value = mock_client + mock_channel = MagicMock() + mock_transport = MagicMock() + + mock_client.get_transport.return_value = mock_transport + mock_transport.open_session.return_value = mock_channel + mock_channel.recv_exit_status.return_value = 1 + mock_channel.makefile.return_value = [to_bytes("")] + mock_channel.makefile_stderr.return_value = [to_bytes('cat: not found')] + + connection._connected = True + connection.ssh = mock_client + + with pytest.raises(AnsibleError, match='cat not found in path of container:'): + connection.fetch_file('/remote/path', '/local/path') + + +def test_close(connection): + """ Test connection close """ + mock_ssh = MagicMock() + connection.ssh = mock_ssh + connection._connected = True + + connection.close() + + assert mock_ssh.close.called, 'ssh.close was not called' + assert not connection._connected, 'self._connected is still True' + + +def test_close_with_lock_file(connection): + """ Test close method with lock file creation """ + connection._any_keys_added = MagicMock(return_value=True) + connection._connected = True + connection.keyfile = '/tmp/qm-remote-known_hosts-test' + connection.set_option('host_key_checking', True) + connection.set_option('lock_file_timeout', 5) + connection.set_option('record_host_keys', True) + connection.ssh = MagicMock() + + lock_file_path = os.path.join(os.path.dirname(connection.keyfile), + f'ansible-{os.path.basename(connection.keyfile)}.lock') + + try: + connection.close() + assert os.path.exists(lock_file_path), 'Lock file was not created' + + lock_stat = os.stat(lock_file_path) + assert lock_stat.st_mode & 0o777 == 0o600, 'Incorrect lock file permissions' + finally: + Path(lock_file_path).unlink(missing_ok=True) + + +@patch('pathlib.Path.unlink') +@patch('os.path.exists') +def test_close_lock_file_time_out_error_handling(mock_exists, mock_unlink, connection): + """ Test close method with lock file timeout error """ + connection._any_keys_added = MagicMock(return_value=True) + connection._connected = True + connection._save_ssh_host_keys = MagicMock() + connection.keyfile = '/tmp/qm-remote-known_hosts-test' + connection.set_option('host_key_checking', True) + connection.set_option('lock_file_timeout', 5) + connection.set_option('record_host_keys', True) + connection.ssh = MagicMock() + + mock_exists.return_value = False + matcher = f'writing lock file for {connection.keyfile} ran in to the timeout of {connection.get_option("lock_file_timeout")}s' + with pytest.raises(AnsibleError, match=matcher): + with patch('os.getuid', return_value=1000), \ + patch('os.getgid', return_value=1000), \ + patch('os.chmod'), patch('os.chown'), \ + patch('os.rename'), \ + patch.object(FileLock, 'lock_file', side_effect=LockTimeout()): + connection.close() + + +@patch('ansible_collections.community.proxmox.plugins.module_utils._filelock.FileLock.lock_file') +@patch('tempfile.NamedTemporaryFile') +@patch('os.chmod') +@patch('os.chown') +@patch('os.rename') +@patch('os.path.exists') +def test_tempfile_creation_and_move(mock_exists, mock_rename, mock_chown, mock_chmod, mock_tempfile, mock_lock_file, connection): + """ Test tempfile creation and move during close """ + connection._any_keys_added = MagicMock(return_value=True) + connection._connected = True + connection._save_ssh_host_keys = MagicMock() + connection.keyfile = '/tmp/qm-remote-known_hosts-test' + connection.set_option('host_key_checking', True) + connection.set_option('lock_file_timeout', 5) + connection.set_option('record_host_keys', True) + connection.ssh = MagicMock() + + mock_exists.return_value = False + + mock_lock_file_instance = MagicMock() + mock_lock_file.return_value = mock_lock_file_instance + mock_lock_file_instance.__enter__.return_value = None + + mock_tempfile_instance = MagicMock() + mock_tempfile_instance.name = '/tmp/mock_tempfile' + mock_tempfile.return_value.__enter__.return_value = mock_tempfile_instance + + mode = 0o644 + uid = 1000 + gid = 1000 + key_dir = os.path.dirname(connection.keyfile) + + with patch('os.getuid', return_value=uid), patch('os.getgid', return_value=gid): + connection.close() + + connection._save_ssh_host_keys.assert_called_once_with('/tmp/mock_tempfile') + mock_chmod.assert_called_once_with('/tmp/mock_tempfile', mode) + mock_chown.assert_called_once_with('/tmp/mock_tempfile', uid, gid) + mock_rename.assert_called_once_with('/tmp/mock_tempfile', connection.keyfile) + mock_tempfile.assert_called_once_with(dir=key_dir, delete=False) + + +@patch('pathlib.Path.unlink') +@patch('tempfile.NamedTemporaryFile') +@patch('ansible_collections.community.proxmox.plugins.module_utils._filelock.FileLock.lock_file') +@patch('os.path.exists') +def test_close_tempfile_error_handling(mock_exists, mock_lock_file, mock_tempfile, mock_unlink, connection): + """ Test tempfile creation error """ + connection._any_keys_added = MagicMock(return_value=True) + connection._connected = True + connection._save_ssh_host_keys = MagicMock() + connection.keyfile = '/tmp/qm-remote-known_hosts-test' + connection.set_option('host_key_checking', True) + connection.set_option('lock_file_timeout', 5) + connection.set_option('record_host_keys', True) + connection.ssh = MagicMock() + + mock_exists.return_value = False + + mock_lock_file_instance = MagicMock() + mock_lock_file.return_value = mock_lock_file_instance + mock_lock_file_instance.__enter__.return_value = None + + mock_tempfile_instance = MagicMock() + mock_tempfile_instance.name = '/tmp/mock_tempfile' + mock_tempfile.return_value.__enter__.return_value = mock_tempfile_instance + + with pytest.raises(AnsibleError, match='error occurred while writing SSH host keys!'): + with patch.object(os, 'chmod', side_effect=Exception()): + connection.close() + mock_unlink.assert_called_with(missing_ok=True) + + +@patch('ansible_collections.community.proxmox.plugins.module_utils._filelock.FileLock.lock_file') +@patch('os.path.exists') +def test_close_with_invalid_host_key(mock_exists, mock_lock_file, connection): + """ Test load_system_host_keys on close with InvalidHostKey error """ + connection._any_keys_added = MagicMock(return_value=True) + connection._connected = True + connection._save_ssh_host_keys = MagicMock() + connection.keyfile = '/tmp/qm-remote-known_hosts-test' + connection.set_option('host_key_checking', True) + connection.set_option('lock_file_timeout', 5) + connection.set_option('record_host_keys', True) + connection.ssh = MagicMock() + connection.ssh.load_system_host_keys.side_effect = paramiko.hostkeys.InvalidHostKey( + "Bad Line!", Exception('Something crashed!')) + + mock_exists.return_value = False + + mock_lock_file_instance = MagicMock() + mock_lock_file.return_value = mock_lock_file_instance + mock_lock_file_instance.__enter__.return_value = None + + with pytest.raises(AnsibleConnectionFailure, match="Invalid host key: Bad Line!"): + connection.close() + + +def test_reset(connection): + """ Test connection reset """ + connection._connected = True + connection.close = MagicMock() + connection._connect = MagicMock() + + connection.reset() + + connection.close.assert_called_once() + connection._connect.assert_called_once() + + connection._connected = False + connection.reset() + assert connection.close.call_count == 1 From 7fda1ae270bc44769775c49b804d082f0d09ec12 Mon Sep 17 00:00:00 2001 From: Nils Stein <31704359+mietzen@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:40:20 +0200 Subject: [PATCH 05/10] Fixing sanity check --- plugins/connection/proxmox_qm_remote.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/connection/proxmox_qm_remote.py b/plugins/connection/proxmox_qm_remote.py index e3268af7..aa699bce 100644 --- a/plugins/connection/proxmox_qm_remote.py +++ b/plugins/connection/proxmox_qm_remote.py @@ -675,7 +675,7 @@ def _check_guest_agent(self) -> None: if self.get_option('proxmox_ssh_user') != 'root': qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd - returncode, _, _ = self._execute_ssh_command(qm_cmd) + returncode, stdout, stderr = self._execute_ssh_command(qm_cmd) if returncode != 0: raise AnsibleError( From b90b105e56229eb3080b14691ab0247f191f9ac0 Mon Sep 17 00:00:00 2001 From: Nils Stein <31704359+mietzen@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:52:47 +0200 Subject: [PATCH 06/10] Fixing tests --- .../connection/test_proxmox_qm_remote.py | 147 ++++++++++-------- 1 file changed, 82 insertions(+), 65 deletions(-) diff --git a/tests/unit/plugins/connection/test_proxmox_qm_remote.py b/tests/unit/plugins/connection/test_proxmox_qm_remote.py index c8f94274..72e382e4 100644 --- a/tests/unit/plugins/connection/test_proxmox_qm_remote.py +++ b/tests/unit/plugins/connection/test_proxmox_qm_remote.py @@ -7,6 +7,8 @@ from __future__ import (annotations, absolute_import, division, print_function) __metaclass__ = type +import base64 +import json import os import pytest @@ -26,12 +28,19 @@ @pytest.fixture def connection(): + """Fixture to create a Connection instance for testing""" play_context = PlayContext() in_stream = StringIO() + conn = connection_loader.get('community.proxmox.proxmox_qm_remote', play_context, in_stream) + + conn.set_option('remote_addr', '192.168.1.100') conn.set_option('remote_user', 'root') conn.set_option('password', 'password') + conn.set_option('proxmox_ssh_user', 'root') + conn.set_option('vmid', 100) + return conn @@ -206,39 +215,51 @@ def test_save_ssh_host_keys(mock_tempfile, mock_stat, mock_exists, connection): def test_build_qm_command(connection): - """ Test qm command building with different users """ + """Test qm command building with different users""" connection.set_option('vmid', '100') - cmd = connection._build_qm_command('/bin/sh -c "ls -la"') - assert cmd == '/usr/sbin/qm exec 100 -- /bin/sh -c "ls -la"' + cmd = connection._build_qm_command(['/bin/sh', '-c', 'ls -la']) + expected = ['/usr/sbin/qm', 'guest', 'exec', '100', '--timeout', '60', '--', '/bin/sh', '-c', 'ls -la'] + assert cmd == expected - connection.set_option('remote_user', 'user') + connection.set_option('proxmox_ssh_user', 'user') connection.set_option('proxmox_become_method', 'sudo') - cmd = connection._build_qm_command('/bin/sh -c "ls -la"') - assert cmd == 'sudo /usr/sbin/qm exec 100 -- /bin/sh -c "ls -la"' + cmd = connection._build_qm_command(['/bin/sh', '-c', 'ls -la']) + expected = ['sudo', '/usr/sbin/qm', 'guest', 'exec', '100', '--timeout', '60', '--', '/bin/sh', '-c', 'ls -la'] + assert cmd == expected + + cmd = connection._build_qm_command(['/bin/sh', '-c', 'cat'], pass_stdin=True) + expected = ['sudo', '/usr/sbin/qm', 'guest', 'exec', '100', '--pass-stdin', '1', '--timeout', '60', '--', '/bin/sh', '-c', 'cat'] + assert cmd == expected @patch('paramiko.SSHClient') def test_exec_command_success(mock_ssh, connection): - """ Test successful command execution """ + """Test successful command execution""" mock_client = MagicMock() - mock_ssh.return_value = mock_client mock_channel = MagicMock() mock_transport = MagicMock() mock_client.get_transport.return_value = mock_transport mock_transport.open_session.return_value = mock_channel - mock_channel.recv_exit_status.return_value = 0 - mock_channel.makefile.return_value = [to_bytes('stdout')] - mock_channel.makefile_stderr.return_value = [to_bytes("")] - connection._connected = True connection.ssh = mock_client + connection.become = None + + mock_channel.recv_exit_status.return_value = 0 + qm_response = { + 'exitcode': 0, + 'exited': 1, + 'out-data': 'test output' + } + mock_channel.makefile.return_value = [to_bytes(json.dumps(qm_response))] + mock_channel.makefile_stderr.return_value = [to_bytes("")] returncode, stdout, stderr = connection.exec_command('ls -la') + assert returncode == 0 + assert stdout == b'test output' mock_transport.open_session.assert_called_once() - mock_channel.get_pty.assert_called_once() mock_transport.set_keepalive.assert_called_once_with(5) @@ -280,7 +301,7 @@ def test_exec_command_session_open_failure(mock_ssh, connection): @patch('paramiko.SSHClient') def test_exec_command_with_privilege_escalation(mock_ssh, connection): - """ Test exec_command with privilege escalation """ + """Test exec_command with privilege escalation""" mock_client = MagicMock() mock_channel = MagicMock() mock_transport = MagicMock() @@ -298,12 +319,19 @@ def test_exec_command_with_privilege_escalation(mock_ssh, connection): mock_channel.recv.return_value = b'[sudo] password:' mock_channel.recv_exit_status.return_value = 0 - mock_channel.makefile.return_value = [b""] - mock_channel.makefile_stderr.return_value = [b""] + + qm_response = { + 'exitcode': 0, + 'exited': 1, + 'out-data': 'test output' + } + mock_channel.makefile.return_value = [to_bytes(json.dumps(qm_response))] + mock_channel.makefile_stderr.return_value = [to_bytes("")] returncode, stdout, stderr = connection.exec_command('sudo test command') mock_channel.sendall.assert_called_once_with(b'sudo_password\n') + assert returncode == 0 @patch('paramiko.SSHClient') @@ -326,16 +354,22 @@ def test_exec_command_with_forward_agent(mock_ssh, connection): def test_put_file(connection): - """ Test putting a file to the remote system """ - connection.exec_command = MagicMock() - connection.exec_command.return_value = (0, b"", b"") + """Test putting a file using chunked transfer""" + connection._check_guest_agent = MagicMock() + connection._check_required_commands = MagicMock() + connection._qm_exec = MagicMock() + connection._verify_file_transfer = MagicMock() + test_content = b'test content that is longer than usual to test chunking behavior' - with patch('builtins.open', create=True) as mock_open: - mock_open.return_value.__enter__.return_value.read.return_value = b'test content' - connection.put_file('/local/path', '/remote/path') + with patch('builtins.open', mock_open(read_data=test_content)): + with patch('os.path.getsize', return_value=len(test_content)): + connection.put_file('/local/path', '/remote/path') - connection.exec_command.assert_called_once_with("/bin/sh -c 'cat > /remote/path'", in_data=b'test content', sudoable=False) + connection._check_guest_agent.assert_called_once() + connection._check_required_commands.assert_called_once() + connection._verify_file_transfer.assert_called_once_with('/local/path', '/remote/path', len(test_content)) + assert connection._qm_exec.call_count >= 1 @patch('paramiko.SSHClient') def test_put_file_general_error(mock_ssh, connection): @@ -358,37 +392,32 @@ def test_put_file_general_error(mock_ssh, connection): connection.put_file('/remote/path', '/local/path') -@patch('paramiko.SSHClient') -def test_put_file_cat_not_found(mock_ssh, connection): - """ Test command execution when cat is not found """ - mock_client = MagicMock() - mock_ssh.return_value = mock_client - mock_channel = MagicMock() - mock_transport = MagicMock() - - mock_client.get_transport.return_value = mock_transport - mock_transport.open_session.return_value = mock_channel - mock_channel.recv_exit_status.return_value = 1 - mock_channel.makefile.return_value = [to_bytes("")] - mock_channel.makefile_stderr.return_value = [to_bytes('cat: not found')] - - connection._connected = True - connection.ssh = mock_client +def test_put_file_cat_not_found(connection): + """Test put_file when required commands are missing""" + connection._check_guest_agent = MagicMock() + connection._check_required_commands = MagicMock(side_effect=AnsibleError("Command 'cat' is not available on the VM")) - with pytest.raises(AnsibleError, match='cat not found in path of container:'): - connection.fetch_file('/remote/path', '/local/path') + with pytest.raises(AnsibleError, match="error occurred while putting file"): + connection.put_file('/local/path', '/remote/path') def test_fetch_file(connection): - """ Test fetching a file from the remote system """ - connection.exec_command = MagicMock() - connection.exec_command.return_value = (0, b'test content', b"") - - with patch('builtins.open', create=True) as mock_open: + """Test fetching a file using chunked transfer""" + connection._check_guest_agent = MagicMock() + connection._check_required_commands = MagicMock() + connection._verify_file_transfer = MagicMock() + test_content = b'test content from remote file' + encoded_content = base64.standard_b64encode(test_content).decode('ascii') + connection._qm_exec = MagicMock(side_effect=[str(len(test_content)), encoded_content]) + + with patch('builtins.open', mock_open()) as mock_file: connection.fetch_file('/remote/path', '/local/path') - connection.exec_command.assert_called_once_with("/bin/sh -c 'cat /remote/path'", sudoable=False) - mock_open.assert_called_with('/local/path', 'wb') + connection._check_guest_agent.assert_called_once() + connection._check_required_commands.assert_called_once() + connection._verify_file_transfer.assert_called_once() + + mock_file.assert_called_with('/local/path', 'wb') @patch('paramiko.SSHClient') @@ -412,24 +441,12 @@ def test_fetch_file_general_error(mock_ssh, connection): connection.fetch_file('/remote/path', '/local/path') -@patch('paramiko.SSHClient') -def test_fetch_file_cat_not_found(mock_ssh, connection): - """ Test command execution when cat is not found """ - mock_client = MagicMock() - mock_ssh.return_value = mock_client - mock_channel = MagicMock() - mock_transport = MagicMock() - - mock_client.get_transport.return_value = mock_transport - mock_transport.open_session.return_value = mock_channel - mock_channel.recv_exit_status.return_value = 1 - mock_channel.makefile.return_value = [to_bytes("")] - mock_channel.makefile_stderr.return_value = [to_bytes('cat: not found')] - - connection._connected = True - connection.ssh = mock_client +def test_fetch_file_cat_not_found(connection): + """Test fetch_file when required commands are missing""" + connection._check_guest_agent = MagicMock() + connection._check_required_commands = MagicMock(side_effect=AnsibleError("Command 'cat' is not available on the VM")) - with pytest.raises(AnsibleError, match='cat not found in path of container:'): + with pytest.raises(AnsibleError, match="error occurred while fetching file"): connection.fetch_file('/remote/path', '/local/path') From 3b4f2008a2c71a73da092702fba06960269d64d4 Mon Sep 17 00:00:00 2001 From: Nils Stein <31704359+mietzen@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:55:23 +0200 Subject: [PATCH 07/10] Sanity Check fixes --- tests/unit/plugins/connection/test_proxmox_qm_remote.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/plugins/connection/test_proxmox_qm_remote.py b/tests/unit/plugins/connection/test_proxmox_qm_remote.py index 72e382e4..cfb53df6 100644 --- a/tests/unit/plugins/connection/test_proxmox_qm_remote.py +++ b/tests/unit/plugins/connection/test_proxmox_qm_remote.py @@ -416,7 +416,7 @@ def test_fetch_file(connection): connection._check_guest_agent.assert_called_once() connection._check_required_commands.assert_called_once() connection._verify_file_transfer.assert_called_once() - + mock_file.assert_called_with('/local/path', 'wb') From 87d1aa81084550096136e9b83ff5a2a20754066f Mon Sep 17 00:00:00 2001 From: Nils Stein <31704359+mietzen@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:59:15 +0200 Subject: [PATCH 08/10] Added more qm specific tests --- .../connection/test_proxmox_qm_remote.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/unit/plugins/connection/test_proxmox_qm_remote.py b/tests/unit/plugins/connection/test_proxmox_qm_remote.py index cfb53df6..9787d364 100644 --- a/tests/unit/plugins/connection/test_proxmox_qm_remote.py +++ b/tests/unit/plugins/connection/test_proxmox_qm_remote.py @@ -353,6 +353,43 @@ def test_exec_command_with_forward_agent(mock_ssh, connection): mock_agent_handler.assert_called_once_with(mock_channel) +def test_qm_exec_failure(connection): + """Test _qm_exec with command failure""" + connection._build_qm_command = MagicMock(return_value=['qm', 'guest', 'exec', '100', '--', 'false']) + connection._execute_ssh_command = MagicMock(return_value=(1, b'', b'Command failed')) + + with pytest.raises(AnsibleError, match='qm command failed'): + connection._qm_exec(['false']) + + +def test_qm_exec_vm_command_failure(connection): + """Test _qm_exec with VM command failure""" + connection._build_qm_command = MagicMock(return_value=['qm', 'guest', 'exec', '100', '--', 'false']) + + vm_response = { + 'exitcode': 1, + 'exited': 1, + 'out-data': None + } + connection._execute_ssh_command = MagicMock(return_value=(0, json.dumps(vm_response).encode(), b'')) + + with pytest.raises(AnsibleError, match='VM command failed'): + connection._qm_exec(['false']) + + +@patch('paramiko.SSHClient') +def test_check_guest_agent_not_responding(mock_ssh, connection): + """Test guest agent check when not responding""" + mock_client = MagicMock() + connection.ssh = mock_client + connection._connected = True + + connection._execute_ssh_command = MagicMock(return_value=(1, b'', b'guest agent not responding')) + + with pytest.raises(AnsibleError, match='Guest agent is not installed or not responding'): + connection._check_guest_agent() + + def test_put_file(connection): """Test putting a file using chunked transfer""" connection._check_guest_agent = MagicMock() @@ -401,6 +438,14 @@ def test_put_file_cat_not_found(connection): connection.put_file('/local/path', '/remote/path') +def test_put_file_error_handling(connection): + """Test put_file error handling""" + connection._check_guest_agent = MagicMock(side_effect=Exception("Guest agent error")) + + with pytest.raises(AnsibleError, match='error occurred while putting file'): + connection.put_file('/local/path', '/remote/path') + + def test_fetch_file(connection): """Test fetching a file using chunked transfer""" connection._check_guest_agent = MagicMock() @@ -450,6 +495,41 @@ def test_fetch_file_cat_not_found(connection): connection.fetch_file('/remote/path', '/local/path') +def test_fetch_file_error_handling(connection): + """Test fetch_file error handling""" + connection._check_guest_agent = MagicMock(side_effect=Exception("Guest agent error")) + + with pytest.raises(AnsibleError, match='error occurred while fetching file'): + connection.fetch_file('/remote/path', '/local/path') + + +def test_fetch_file_empty_file(connection): + """Test fetching an empty or non-existent file""" + connection._check_guest_agent = MagicMock() + connection._check_required_commands = MagicMock() + connection._qm_exec = MagicMock(return_value='0') + + with pytest.raises(AnsibleError, match='File .* does not exist or is empty'): + connection.fetch_file('/remote/path', '/local/path') + + +def test_verify_file_transfer_size_mismatch(connection): + """Test file verification with size mismatch""" + connection._qm_exec = MagicMock(return_value='100') + + with pytest.raises(AnsibleError, match='File size mismatch'): + connection._verify_file_transfer('/local/path', '/remote/path', 50) + + +def test_verify_file_transfer_checksum_mismatch(connection): + """Test file verification with checksum mismatch""" + connection._qm_exec = MagicMock(side_effect=['50', 'wrong_checksum']) + + with patch('builtins.open', mock_open(read_data=b'test content')): + with pytest.raises(AnsibleError, match='Checksum mismatch'): + connection._verify_file_transfer('/local/path', '/remote/path', 50) + + def test_close(connection): """ Test connection close """ mock_ssh = MagicMock() From 5c87b63756bd28a777ca11e523f76bc3b87258e7 Mon Sep 17 00:00:00 2001 From: Nils Stein <31704359+mietzen@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:02:59 +0200 Subject: [PATCH 09/10] Sanity Check fixes --- tests/unit/plugins/connection/test_proxmox_qm_remote.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/plugins/connection/test_proxmox_qm_remote.py b/tests/unit/plugins/connection/test_proxmox_qm_remote.py index 9787d364..54e9df1f 100644 --- a/tests/unit/plugins/connection/test_proxmox_qm_remote.py +++ b/tests/unit/plugins/connection/test_proxmox_qm_remote.py @@ -34,7 +34,6 @@ def connection(): conn = connection_loader.get('community.proxmox.proxmox_qm_remote', play_context, in_stream) - conn.set_option('remote_addr', '192.168.1.100') conn.set_option('remote_user', 'root') conn.set_option('password', 'password') @@ -408,6 +407,7 @@ def test_put_file(connection): assert connection._qm_exec.call_count >= 1 + @patch('paramiko.SSHClient') def test_put_file_general_error(mock_ssh, connection): """ Test put_file with general error """ From 90e3d8b0d9188556d21c84dc319b7eac8050823c Mon Sep 17 00:00:00 2001 From: Nils Stein <31704359+mietzen@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:37:50 +0200 Subject: [PATCH 10/10] Fixing tests --- .../connection/test_proxmox_qm_remote.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/unit/plugins/connection/test_proxmox_qm_remote.py b/tests/unit/plugins/connection/test_proxmox_qm_remote.py index 54e9df1f..3b9b6c49 100644 --- a/tests/unit/plugins/connection/test_proxmox_qm_remote.py +++ b/tests/unit/plugins/connection/test_proxmox_qm_remote.py @@ -513,23 +513,6 @@ def test_fetch_file_empty_file(connection): connection.fetch_file('/remote/path', '/local/path') -def test_verify_file_transfer_size_mismatch(connection): - """Test file verification with size mismatch""" - connection._qm_exec = MagicMock(return_value='100') - - with pytest.raises(AnsibleError, match='File size mismatch'): - connection._verify_file_transfer('/local/path', '/remote/path', 50) - - -def test_verify_file_transfer_checksum_mismatch(connection): - """Test file verification with checksum mismatch""" - connection._qm_exec = MagicMock(side_effect=['50', 'wrong_checksum']) - - with patch('builtins.open', mock_open(read_data=b'test content')): - with pytest.raises(AnsibleError, match='Checksum mismatch'): - connection._verify_file_transfer('/local/path', '/remote/path', 50) - - def test_close(connection): """ Test connection close """ mock_ssh = MagicMock()