diff --git a/plugins/connection/proxmox_qm_remote.py b/plugins/connection/proxmox_qm_remote.py new file mode 100644 index 00000000..aa699bce --- /dev/null +++ b/plugins/connection/proxmox_qm_remote.py @@ -0,0 +1,993 @@ +# -*- coding: utf-8 -*- +# Derived from ansible/plugins/connection/paramiko_ssh.py (c) 2012, Michael DeHaan +# 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 + +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_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 + 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: +# qemu: +# hosts: +# vm-1: +# ansible_host: 10.0.0.10 +# proxmox_vmid: 100 +# ansible_connection: community.proxmox.proxmox_qm_remote +# 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: 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: 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 Proxmox VM + ansible.builtin.ping: +""" + +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')) + + 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', ()) + 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('proxmox_ssh_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_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'))] + + if pass_stdin: + qm_cmd += ['--pass-stdin', '1'] + + 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(cmd) + chan.exec_command(command) + + if data_in: + chan.sendall(data_in) + chan.shutdown_write() + + stdout = b''.join(chan.makefile('rb', bufsize)) + stderr = b''.join(chan.makefile_stderr('rb', bufsize)) + returncode = chan.recv_exit_status() + + return returncode, stdout, stderr + + except Exception as e: + raise AnsibleError(f'SSH command execution failed: {to_text(e)}') + + 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)) + + returncode, stdout, stderr = self._execute_ssh_command(qm_cmd, data_in) + + 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') + + 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('proxmox_ssh_user') != 'root': + qm_cmd = [self.get_option('proxmox_become_method')] + qm_cmd + + returncode, stdout, stderr = self._execute_ssh_command(qm_cmd) + + 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 _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 = ' '.join(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"))}') + + # 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) + operator = '>>' # After first chunk, append + + # Verify file transfer + 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)}') + + 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 + 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)}') + + 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 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..3b9b6c49 --- /dev/null +++ b/tests/unit/plugins/connection/test_proxmox_qm_remote.py @@ -0,0 +1,686 @@ +# -*- 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 base64 +import json +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(): + """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 + + +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']) + expected = ['/usr/sbin/qm', 'guest', 'exec', '100', '--timeout', '60', '--', '/bin/sh', '-c', 'ls -la'] + assert cmd == expected + + connection.set_option('proxmox_ssh_user', 'user') + connection.set_option('proxmox_become_method', 'sudo') + 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""" + 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 = 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_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 + + 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') +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_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() + 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', mock_open(read_data=test_content)): + with patch('os.path.getsize', return_value=len(test_content)): + connection.put_file('/local/path', '/remote/path') + + 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): + """ 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') + + +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="error occurred while putting file"): + 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() + 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._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') +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') + + +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="error occurred while fetching file"): + 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_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