diff --git a/README.md b/README.md index 5f000d8..bd4bbf7 100644 --- a/README.md +++ b/README.md @@ -92,5 +92,20 @@ Commands: If you need help on any subcommand, run `hypy.py COMMAND --help`. Further details on subcommands: https://github.com/avanzzzi/hypy/wiki +## Window resizing on Linux/Mac +On Linux and Mac, hypy passes `/smart-sizing` to xfreerdp, so the VM window can be freely resized. The image will scale to fit the window without black areas. + +For **true dynamic resolution** (the VM display actually changes resolution to match the window size), Hyper-V Enhanced Session must be enabled on the host. Run the following on the Windows Hyper-V host as Administrator: + +```powershell +Set-VMHost -EnableEnhancedSessionMode $true +# or +Set-VM -VMName "YourVMName" -EnhancedSessionTransportType HvSocket +``` + +> **Note:** Linux guest VMs also require `xrdp` installed inside the VM to support Enhanced Session. + +Once Enhanced Session is active, replace `/smart-sizing` with `/dynamic-resolution` in `hypy/modules/hvclient.py` for the best experience. + ## tests A tox.ini file is included for execution of style check and unit tests. diff --git a/hypy/cli_main.py b/hypy/cli_main.py index 676535d..92b4fe3 100644 --- a/hypy/cli_main.py +++ b/hypy/cli_main.py @@ -34,7 +34,7 @@ def show_status(ctx, by_name, ident): vms = hvclient.parse_result(rs) cache.update_cache(vms) cache_vms = cache.list_vms() - printer.print_list_vms(cache_vms, name) + printer.print_list_vms(cache_vms, name, show_ip=False) rs_snaps = hvclient.list_vm_snaps(name) snaps = hvclient.parse_result(rs_snaps) printer.print_vm_snaps(snaps, name, vms['ParentSnapshotName']) @@ -45,24 +45,28 @@ def show_status(ctx, by_name, ident): help='Syncronize with server updating local cache') @click.option('--name', '-n', help='Filter virtual machines by name') @click.option('--rem', '-r', is_flag=True, default=False, help='Remove old cache before sync') -def list_vms(sync, name, rem): +@click.option('--ip', '-i', is_flag=True, default=False, help='Show IP addresses') +def list_vms(sync, name, rem, ip): remove_old_cache = rem or cache.need_update() if sync or remove_old_cache: rs = hvclient.get_vm(name) vms = hvclient.parse_result(rs) + if ip: + vms = hvclient.add_ip_addresses(vms) if remove_old_cache: cache.remove_cache() cache.update_cache(vms) cache_vms = cache.list_vms() - printer.print_list_vms(cache_vms, name) + printer.print_list_vms(cache_vms, name, show_ip=ip) @cli.command("ls", help='List updated virtual machines and its indexes') @click.option('--name', '-n', help='Filter virtual machines by name') @click.option('--rem', '-r', is_flag=True, default=False, help='Remove old cache before sync') +@click.option('--ip', '-i', is_flag=True, default=False, help='Show IP addresses') @click.pass_context -def ls(ctx, name, rem): - ctx.invoke(list_vms, sync=True, name=name, rem=rem) +def ls(ctx, name, rem, ip): + ctx.invoke(list_vms, sync=True, name=name, rem=rem, ip=ip) @cli.command(help="Connect to virtual machine identified by index") diff --git a/hypy/cli_snap.py b/hypy/cli_snap.py index 09ea902..de144fa 100644 --- a/hypy/cli_snap.py +++ b/hypy/cli_snap.py @@ -18,7 +18,7 @@ def snap_ls(by_name, ident): vms = hvclient.parse_result(rs) cache.update_cache(vms) cache_vms = cache.list_vms() - printer.print_list_vms(cache_vms, name) + printer.print_list_vms(cache_vms, name, show_ip=False) rs_snaps = hvclient.list_vm_snaps(name) snaps = hvclient.parse_result(rs_snaps) printer.print_vm_snaps(snaps, name, vms['ParentSnapshotName']) diff --git a/hypy/modules/hvclient.py b/hypy/modules/hvclient.py index bb914fe..349e190 100644 --- a/hypy/modules/hvclient.py +++ b/hypy/modules/hvclient.py @@ -3,6 +3,7 @@ from base64 import b64encode, b64decode from collections import namedtuple from subprocess import DEVNULL, PIPE, Popen, TimeoutExpired +from typing import Optional from paramiko import AutoAddPolicy, SSHClient from winrm import Protocol, Response @@ -13,6 +14,8 @@ 'production': 3, 'productiononly': 4} +IPV4_LINK_LOCAL_PREFIX = '169.254.' + def connect(vm_id: str, vm_name: str, vm_index: str): """ @@ -24,21 +27,29 @@ def connect(vm_id: str, vm_name: str, vm_index: str): vm_index: Index of the vm in the cache file. """ user = config['user'] - passw = b64decode(config['pass']) + passw = config['pass'] + domain = config['domain'] host = config['host'] - if platform.uname()[0] == "Windows": + if isinstance(passw, bytes): + passw = passw.decode('utf-8') + + is_windows = platform.uname()[0] == "Windows" + if is_windows: freerdp_bin = "wfreerdp.exe" else: freerdp_bin = "xfreerdp" cmd = [freerdp_bin, '/v:{}'.format(host), '/vmconnect:{}'.format(vm_id), - '/u:{}'.format(user), + '/u:{}'.format(r'{}\{}'.format(domain, user)), '/p:{}'.format(passw), '/t:{} [{}] {}'.format(host, vm_index, vm_name), '/cert:ignore'] + if not is_windows: + cmd.append('/smart-sizing') + try: handle = Popen(cmd, stdout=DEVNULL, stderr=PIPE) errs = handle.communicate(timeout=5)[1] @@ -319,6 +330,92 @@ def set_switch(vm_name: str, switch_name: str) -> Response: return rs +def _select_primary_ip(ip_addresses) -> Optional[str]: + """ + Select the primary IP address from a list of IP addresses. + + Strategy: + 1. Prefer non-link-local IPv4 addresses (not 169.254.x.x) + 2. Fall back to any available IP if no standard IPv4 found + 3. Return None if no addresses available + + Args: + ip_addresses: List of IP addresses or single IP address string. + Returns: + Selected IP address string or None. + """ + if not ip_addresses: + return None + + if isinstance(ip_addresses, str): + return ip_addresses + + if isinstance(ip_addresses, list): + ipv4_addrs = [ip for ip in ip_addresses + if ':' not in ip and not ip.startswith(IPV4_LINK_LOCAL_PREFIX)] + if ipv4_addrs: + return ipv4_addrs[0] + # Fallback to first available IP + return ip_addresses[0] if ip_addresses else None + + return None + + +def _get_all_vm_ip_addresses() -> Response: + """ + Retrieve IP addresses for all VMs in a single batch call. + + Returns: + Info obtained from remote hyper-v host containing VMName and IPAddresses. + """ + ps_script = 'Get-VMNetworkAdapter -VMName * | Select VMName, IPAddresses | ConvertTo-Json' + rs = run_ps(ps_script) + return rs + + +def add_ip_addresses(vms_json: dict) -> dict: + """ + Add IP addresses to VM information by fetching all IPs in one batch call. + + Args: + vms_json: Dict or list of VMs from get_vm(). + Returns: + Same structure with IPAddress field added to each VM. + """ + if isinstance(vms_json, dict): + vms_json = [vms_json] + + for vm in vms_json: + vm['IPAddress'] = None + + try: + rs = _get_all_vm_ip_addresses() + adapters = parse_result(rs) + + if adapters and isinstance(adapters, dict): + adapters = [adapters] + + ip_map = {} + if adapters: + for adapter in adapters: + vm_name = adapter.get('VMName') + ip_addresses = adapter.get('IPAddresses') + + if vm_name and ip_addresses: + ip_map[vm_name] = _select_primary_ip(ip_addresses) + + for vm in vms_json: + if vm['Name'] in ip_map: + vm['IPAddress'] = ip_map[vm['Name']] + + except Exception: + # If batch call fails, VMs already have IPAddress = None from initialization + # and hypy will not crash + pass + + return vms_json + + def run_ps(ps: str) -> Response: """ Run powershell script on target machine. @@ -350,7 +447,7 @@ def run_cmd_ssh(cmd: str) -> Response: ssh_client.load_system_host_keys() ssh_client.set_missing_host_key_policy(AutoAddPolicy()) ssh_client.connect(username=config['user'], - password=b64decode(config['pass']), + password=config['pass'], hostname=config['host'], port=int(config['ssh_port']), allow_agent=False, @@ -380,7 +477,7 @@ def run_cmd_winrm(cmd: str) -> Response: transport='ntlm', username=r'{}\{}'.format(config['domain'], config['user']), - password=b64decode(config['pass']), + password=config['pass'], server_cert_validation='ignore') shell_id = client.open_shell() diff --git a/hypy/modules/printer.py b/hypy/modules/printer.py index b113a28..f6723ac 100644 --- a/hypy/modules/printer.py +++ b/hypy/modules/printer.py @@ -12,7 +12,8 @@ 6: 'saved'} ADJ = {'index': 3, 'state': 7, - 'name': 30} + 'name': 30, + 'ip': 15} def print_vm_switch(switch_json: dict): @@ -71,7 +72,7 @@ def print_vm_snaps(snaps_json: dict, vm_name: str, current_snap: str): print("{} has no snapshots".format(vm_name)) -def print_list_vms(vms_json: dict, filter_vms: str): +def print_list_vms(vms_json: dict, filter_vms: str, show_ip: bool = False): """ Print list of virtual machines. @@ -79,15 +80,19 @@ def print_list_vms(vms_json: dict, filter_vms: str): vms_json: Dict containing the table of vms. filter_vms: Filter to be applied at the output. Only the vms whose name matches the filter will be shown. + show_ip: Whether to show IP addresses column. """ # Listing # print("-- Hyper-V Virtual Machine Listing --") # Header - print("{} {} {} {}".format("Index".rjust(ADJ['index']), + header = "{} {} {}".format("Index".rjust(ADJ['index']), "State".ljust(ADJ['state']), - "Name".ljust(ADJ['name']), - "Uptime")) + "Name".ljust(ADJ['name'])) + if show_ip: + header += " {}".format("IP Address".ljust(ADJ['ip'])) + header += " Uptime" + print(header) if filter_vms: vms_show = [vm for vm in vms_json if fnmatch(vm['Name'], filter_vms)] @@ -99,5 +104,16 @@ def print_list_vms(vms_json: dict, filter_vms: str): index = str(vms_json.index(vm)).rjust(ADJ['index']) state = STATES.get(vm['State'], "unknown").ljust(ADJ['state']) name = str(vm['Name']).ljust(ADJ['name']) + + row = "[{}] {} {}".format(index, state, name) + + if show_ip: + ip_addr = vm.get('IPAddress', 'N/A') + if ip_addr is None: + ip_addr = 'N/A' + row += " {}".format(str(ip_addr).ljust(ADJ['ip'])) + uptime = str(timedelta(hours=vm['Uptime']['TotalHours'])) - print("[{}] {} {} {}".format(index, state, name, uptime)) + row += " {}".format(uptime) + + print(row)