Skip to content
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 9 additions & 5 deletions hypy/cli_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion hypy/cli_snap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
107 changes: 102 additions & 5 deletions hypy/modules/hvclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
"""
Expand All @@ -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]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
28 changes: 22 additions & 6 deletions hypy/modules/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
6: 'saved'}
ADJ = {'index': 3,
'state': 7,
'name': 30}
'name': 30,
'ip': 15}


def print_vm_switch(switch_json: dict):
Expand Down Expand Up @@ -71,23 +72,27 @@ 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.

Args:
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)]
Expand All @@ -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)