├── LICENSE ├── README.md └── vm /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Aleksander Alekseev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # py-vm 2 | 3 | Simple CLI wrapper for VirtualBox. Can be considered a Vagrant replacement in many cases. 4 | 5 | Usage: 6 | 7 | ``` 8 | vm create 9 | vm types 10 | vm list 11 | vm start 12 | vm stop 13 | vm vnc 14 | vm ssh [command] 15 | vm ssh-copy-id 16 | vm change-ports 17 | vm eject-disk 18 | vm delete 19 | vm rename 20 | vm clone 21 | vm export 22 | vm import 23 | ``` 24 | 25 | For proper bash completion add to your ~/.bashrc: 26 | 27 | ``` 28 | complete -W 'create types list start stop vnc ssh ssh-copy-id change-ports '\ 29 | 'eject-disk delete rename clone export import' vm 30 | ``` 31 | 32 | Reddit discussion: [https://redd.it/5ab2th](https://redd.it/5ab2th). 33 | -------------------------------------------------------------------------------- /vm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # vim: set ai et ts=4 sw=4: 3 | 4 | import subprocess 5 | import re 6 | import os 7 | import sys 8 | 9 | # TODO: support `insert-disk` command 10 | # TODO: support `modify` command 11 | # TODO: support `list-full` command: print cpus, mem_mb, disk_mb, 12 | # ssh_port, vnc_port, iso path 13 | # TODO: use argparse: https://docs.python.org/3/library/argparse.html#sub-commands 14 | # TODO: support cpu affinity 15 | 16 | nat_network_name = "VmPyNatNetwork" 17 | nat_network_addr = "10.128.0.0/16" 18 | vnc_plain_pass = "secret" 19 | vnc_encrypted_pass = '\\x2e\\x2d\\xbf\\x57\\x6e\\xb0\\x6c\\x9e' 20 | 21 | 22 | def usage(): 23 | sys.exit(""" 24 | Usage: 25 | vm create 26 | vm types 27 | vm list 28 | vm start 29 | vm stop 30 | vm vnc 31 | vm ssh [command] 32 | vm ssh-copy-id 33 | vm change-ports 34 | vm eject-disk 35 | vm delete 36 | vm rename 37 | vm clone 38 | vm export 39 | vm import 40 | 41 | Homepage: 42 | https://github.com/afiskon/py-vm 43 | """) 44 | 45 | 46 | def sh(cmd): 47 | """ 48 | Run given bash command, return output, throw exception on error 49 | """ 50 | return subprocess.check_output(cmd, shell=True).decode('utf-8') 51 | 52 | 53 | def run(cmd): 54 | """ 55 | Run given bash command, return error code 56 | """ 57 | return subprocess.call(cmd, shell=True) 58 | 59 | 60 | def validate_str(arg): 61 | if arg is None: 62 | return None 63 | if not re.match("""^[A-Za-z0-9_\.-]{1,32}$""", arg): 64 | sys.exit( 65 | """Bad argument '{}' - should be [A-Za-z90-9_\.-] and no longer than 32 characters""".format(arg)) 66 | return arg 67 | 68 | 69 | def validate_num(num): 70 | if num is None: 71 | num = '' 72 | if not re.match("""^[0-9]+$""", num): 73 | sys.exit("Bad argument '{}' - should be a number".format(num)) 74 | return int(num) 75 | 76 | 77 | def list_vms(): 78 | all_vms = sh( 79 | '''vboxmanage list vms | cut -d '"' -f 2''').split("\n") 80 | all_vms.sort() 81 | running_vms = set( 82 | sh('''vboxmanage list runningvms | cut -d '"' -f 2''').split("\n")) 83 | 84 | print("{:32} {:12} {:8}".format("VM", "STATUS", "SSH PORT")) 85 | print("-" * (32 + 1 + 12 + 2 + 8)) 86 | for vm in all_vms: 87 | if vm == "": 88 | continue 89 | status = "powered off" 90 | if vm in running_vms: 91 | status = "running" 92 | ssh_port = ' (none)' 93 | try: 94 | ssh_port = get_vm_ssh_port(vm) 95 | except: 96 | pass 97 | print("{:32} {:12} {:8}".format(vm, status, ssh_port)) 98 | 99 | 100 | def list_os_types(): 101 | lines = sh('''vboxmanage list ostypes | egrep ^ID''').strip().split("\n") 102 | vmtypes = [] 103 | for line in lines: 104 | vmtypes.append(re.search('(\\S+)$', line).group(1)) 105 | 106 | vmtypes.sort() 107 | for vmtype in vmtypes: 108 | print(vmtype) 109 | 110 | 111 | def ensure_nat_network_exists(): 112 | code = run("vboxmanage natnetwork list | grep Name | grep {} > /dev/null".format( 113 | nat_network_name)) 114 | if code != 0: 115 | print("Creating {} network ({})".format( 116 | nat_network_name, nat_network_addr)) 117 | sh(("vboxmanage natnetwork add --netname {} --network {} " + 118 | "--enable --dhcp on --ipv6 off").format(nat_network_name, nat_network_addr)) 119 | 120 | 121 | def get_virtualbox_path(): 122 | line = sh( 123 | """vboxmanage list systemproperties | grep 'Default machine folder'""").strip() 124 | path = re.search("""(\\S+)$""", line).group(1) 125 | if not os.path.isdir(path): 126 | sys.exit(("get_virtualbox_path() - '{}' is not a directory\n" + 127 | "Hint: path shouldn't contain whitespaces\n" + 128 | "Hint: use `vboxmanage setproperty machinefolder /home/user/virtuabox`").format(path)) 129 | return path 130 | 131 | 132 | def get_vm_ssh_port(vm): 133 | line = sh("vboxmanage showvminfo {} | grep ssh-forwarding".format(vm)) 134 | return int(re.search("""host port = (\d+)""", line).group(1)) 135 | 136 | 137 | def get_max_ssh_port(): 138 | max_port = 22000 139 | lines = sh( 140 | "(vboxmanage list vms --long | grep 'ssh-forwarding') || true").split("\n") 141 | for line in lines: 142 | if line == "": 143 | continue 144 | port = int(re.search('host port = (\\d+)', line).group(1)) 145 | if port > max_port: 146 | max_port = port 147 | return max_port 148 | 149 | 150 | def get_max_vnc_port(): 151 | max_port = 33000 152 | lines = sh( 153 | "(vboxmanage list vms --long | grep 'VRDE property: TCP/Ports') || true").split("\n") 154 | for line in lines: 155 | if line == "": 156 | continue 157 | port = int(re.search('"(\\d+)"$', line).group(1)) 158 | if port > max_port: 159 | max_port = port 160 | return max_port 161 | 162 | 163 | def configure_port_forwarding(vm): 164 | ssh_port = get_max_ssh_port() + 1 165 | vnc_port = get_max_vnc_port() + 1 166 | print("VM '{}' - changing ssh port to {} and vnc port to {}".format(vm, ssh_port, vnc_port)) 167 | run("vboxmanage modifyvm {} --natpf1 delete ssh-forwarding 2>/dev/null".format(vm)) 168 | sh("vboxmanage modifyvm {} --nic2 natnetwork --nat-network2 {}".format(vm, nat_network_name)) 169 | sh('vboxmanage modifyvm {} --natpf1 "ssh-forwarding,tcp,,{},,22"'.format(vm, ssh_port)) 170 | sh("vboxmanage modifyvm {} --vrde on".format(vm)) 171 | sh("vboxmanage modifyvm {} --vrdeaddress 127.0.0.1".format(vm)) 172 | sh("vboxmanage modifyvm {} --vrdeport {}".format(vm, vnc_port)) 173 | sh('vboxmanage modifyvm {} --vrdeproperty VNCPassword="{}"'.format(vm, vnc_plain_pass)) 174 | 175 | def create_vm(args): 176 | name = args[2] # already validated 177 | vmtype = args[3] # already validated 178 | cpus = validate_num(args[4]) 179 | mem_mb = validate_num(args[5]) 180 | disk_mb = validate_num(args[6]) 181 | iso_path = args[7] 182 | if not os.path.isfile(iso_path): 183 | sys.exit("Invalid iso_path '{}' - file doesn't exist".format(iso_path)) 184 | disk_path = get_virtualbox_path() + "/" + name + "/" + name + ".vdi" 185 | sh("vboxmanage createvm --name {} --ostype {} --register".format(name, vmtype)) 186 | sh(("vboxmanage modifyvm {} --cpus {} --memory {} " + 187 | "--audio none --usb off --acpi on --boot1 dvd --nic1 nat").format(name, cpus, mem_mb)) 188 | sh("vboxmanage createhd --filename {} --size {}".format(disk_path, disk_mb)) 189 | sh("vboxmanage storagectl {} --name ide-controller --add ide".format(name)) 190 | sh(("vboxmanage storageattach {} --storagectl ide-controller " + 191 | "--port 0 --device 0 --type hdd --medium {}").format(name, disk_path)) 192 | sh(("vboxmanage storageattach {} --storagectl ide-controller " + 193 | "--port 0 --device 1 --type dvddrive --medium {}").format(name, iso_path)) 194 | 195 | ensure_nat_network_exists() 196 | sh("vboxmanage modifyvm {} --nic2 natnetwork --nat-network2 {}".format(name, nat_network_name)) 197 | 198 | configure_port_forwarding(name) 199 | 200 | 201 | def main(): 202 | if len(sys.argv) < 2: 203 | usage() 204 | 205 | args = sys.argv + [None, None, None, None, None, None] 206 | cmd = validate_str(args[1]) 207 | src = validate_str(args[2]) 208 | dst = args[3] 209 | if cmd != "import" and cmd != "export" and cmd != "ssh": 210 | dst = validate_str(dst) 211 | 212 | if cmd == "create": 213 | create_vm(args) 214 | elif cmd == "list": 215 | list_vms() 216 | elif cmd == "types": 217 | list_os_types() 218 | elif src is not None and dst is None: 219 | if cmd == "start": 220 | sh("vboxmanage startvm --type headless {}".format(src)) 221 | elif cmd == "stop": 222 | sh("vboxmanage controlvm {} poweroff".format(src)) 223 | elif cmd == "ssh": 224 | run("ssh -p {} localhost".format(get_vm_ssh_port(src))) 225 | elif cmd == "ssh-copy-id": 226 | run("ssh-copy-id -p {} localhost".format(get_vm_ssh_port(src))) 227 | elif cmd == "vnc": 228 | tmp = sh( 229 | "vboxmanage showvminfo {} | grep VRDE | grep TCP/Ports".format(src)) 230 | port = re.search("""TCP/Ports = "(\d+)""", tmp).group(1) 231 | run(('''(echo -en '{}' | ''' + 232 | '''vncviewer localhost:{} PasswordFile=/dev/stdin) &''').format(vnc_encrypted_pass, port)) 233 | elif cmd == "change-ports": 234 | configure_port_forwarding(src) 235 | elif cmd == "eject-disk": 236 | sh(("vboxmanage storageattach {} --storagectl ide-controller " + 237 | "--port 0 --device 1 --type dvddrive --medium emptydrive").format(src)) 238 | elif cmd == "delete": 239 | sh("vboxmanage unregistervm {} --delete".format(src)) 240 | else: 241 | usage() 242 | elif src is not None and dst is not None: 243 | if cmd == "ssh": 244 | subprocess.call(["ssh", "-p", str(get_vm_ssh_port(src)), "localhost", dst]) 245 | elif cmd == "rename": 246 | sh("vboxmanage modifyvm {} --name {}".format(src, dst)) 247 | elif cmd == "clone": 248 | sh("vboxmanage clonevm {} --name {} --register".format(src, dst)) 249 | configure_port_forwarding(dst) 250 | elif cmd == "export" or cmd == "import": 251 | if not re.match('^.*\.ova$', dst): 252 | sys.exit("Invalid argument '{}' - should be .ova file".format(dst)) 253 | if cmd == "export": 254 | sh("vboxmanage export {} --output {}".format(src, dst)) 255 | else: 256 | sh("vboxmanage import {} --vsys 0 --vmname {}".format(dst, src)) 257 | configure_port_forwarding(src) 258 | else: 259 | usage() 260 | else: 261 | usage() 262 | 263 | if __name__ == "__main__": 264 | main() 265 | --------------------------------------------------------------------------------