1
0
mirror of https://github.com/gryf/boxpy.git synced 2026-02-02 06:05:47 +01:00

12 Commits
1.3 ... 1.5

Author SHA1 Message Date
b7b4ba5cbc Readme update 2022-11-15 20:33:10 +01:00
47766b6cd9 Added ability to point to local qcow2 image.
Instead of downloading image from the network, there is a way to point
out cloud user and image within yaml configuration.
2022-11-15 20:27:14 +01:00
55cb8d5e30 Better messagingn in conf/modules vbox issues 2022-10-16 08:52:55 +02:00
276ddd8681 Added bash completion for start/stop commands. 2022-05-23 12:02:58 +02:00
0fb0d64db6 Added two new commands for starting and stopping VMs. 2022-05-23 12:02:28 +02:00
847279a990 Align case for command help messages. 2022-05-23 12:01:59 +02:00
38ed618b5b Added info if vm is running.
Also, added command for acpi shutdown.
2022-05-23 12:00:55 +02:00
1c39cd1985 Add ability to set different default username.
During cloud init user might want to change default user from the
distribution to something else. It will not be possible to ssh to such
machine using boxpy ssh command - only using ssh directly with the
correct user name would be possible. In this change there is added
possibility for setting username in boxpy_data section with desired
value to be used in ssh command.
2022-04-22 07:53:50 +02:00
ed25a0d208 Fix typo in message. 2022-04-22 07:53:04 +02:00
20120d898a Add error message in case of wrong config 2022-04-22 07:52:22 +02:00
e63d83fc7f Don't clutter current directory with checksum files.
In case of Centos, there is a need to get the checksum file first to
figure out the correct image filename, during that process checksum file
was left alone in the current directory. Place it in the temp dir in the
first place and than remove after we know the right image filename.
2022-02-08 20:39:03 +01:00
0093e32b74 Fix for rebuild command and calling vmdestroy function. 2021-11-04 09:26:38 +01:00
2 changed files with 165 additions and 28 deletions

View File

@@ -102,6 +102,10 @@ Currently, following commands are available:
All of the commands have a range of options, and can be examined by using All of the commands have a range of options, and can be examined by using
``--help`` option. ``--help`` option.
YAML Configuration
------------------
What is more interesting though, is the fact, that you can pass your own What is more interesting though, is the fact, that you can pass your own
`cloud-init`_ yaml file, so that VM can be provisioned in easy way. `cloud-init`_ yaml file, so that VM can be provisioned in easy way.
@@ -203,6 +207,19 @@ configuration additional NIC for virtual machine, i.e:
advanced: advanced:
nic2: intnet nic2: intnet
To select image from local file system, it is possible to set one by providing
it under ``boxpy_data.image`` key:
.. code:: yaml
…
boxpy_data:
image: /path/to/the/qcow2/image
default_user: cloud-user
Note, that default_user is also needed to be provided, as there is no guess,
what is the default username for cloud-init configured within provided image.
License License
------- -------

174
box.py
View File

@@ -125,7 +125,7 @@ _boxpy() {
fi fi
fi fi
opts="create destroy rebuild info list completion ssh" opts="create destroy rebuild info list completion ssh start stop"
if [[ ${cur} == "-q" || ${cur} == "-v" || ${COMP_CWORD} -eq 1 ]] ; then if [[ ${cur} == "-q" || ${cur} == "-v" || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0 return 0
@@ -139,7 +139,8 @@ _boxpy() {
;; ;;
create|rebuild) create|rebuild)
items=(--cpus --disable-nested --disk-size --distro --forwarding items=(--cpus --disable-nested --disk-size --distro --forwarding
--key --memory --hostname --port --config --version --type) --image --key --memory --hostname --port --config --version
--type)
if [[ ${prev} == ${cmd} ]]; then if [[ ${prev} == ${cmd} ]]; then
if [[ ${cmd} = "rebuild" ]]; then if [[ ${cmd} = "rebuild" ]]; then
_vms_comp vms _vms_comp vms
@@ -193,6 +194,16 @@ _boxpy() {
_vms_comp runningvms _vms_comp runningvms
fi fi
;; ;;
start)
if [[ ${prev} == ${cmd} ]]; then
_vms_comp vms
fi
;;
stop)
if [[ ${prev} == ${cmd} ]]; then
_vms_comp runningvms
fi
;;
esac esac
} }
@@ -257,6 +268,10 @@ class BoxVBoxFailure(BoxError):
pass pass
class BoxConfError(BoxError):
pass
class FakeLogger: class FakeLogger:
""" """
print based "logger" class. I like to use 'end' parameter of print print based "logger" class. I like to use 'end' parameter of print
@@ -348,23 +363,26 @@ class FakeLogger:
class Config: class Config:
ATTRS = ('cpus', 'config', 'creator', 'disable_nested', 'disk_size', ATTRS = ('cpus', 'config', 'creator', 'disable_nested', 'disk_size',
'distro', 'forwarding', 'hostname', 'key', 'memory', 'name', 'distro', 'default_user', 'forwarding', 'hostname', 'image',
'port', 'version') 'key', 'memory', 'name', 'port', 'version', 'username')
def __init__(self, args, vbox=None): def __init__(self, args, vbox=None):
self.advanced = None self.advanced = None
self.distro = None self.distro = None
self.default_user = None
self.cpus = None self.cpus = None
self.creator = None self.creator = None
self.disable_nested = 'False' self.disable_nested = 'False'
self.disk_size = None self.disk_size = None
self.forwarding = {} self.forwarding = {}
self.hostname = None self.hostname = None
self.image = None
self.key = None self.key = None
self.memory = None self.memory = None
self.name = args.name # this one is not stored anywhere self.name = args.name # this one is not stored anywhere
self.port = None # at least is not even tried to be retrieved self.port = None # at least is not even tried to be retrieved
self.version = None self.version = None
self.username = None
self._conf = {} self._conf = {}
# set defaults stored in hard coded yaml # set defaults stored in hard coded yaml
@@ -487,7 +505,7 @@ class Config:
self.ssh_key_path = os.path.join(os.path.expanduser("~/.ssh"), self.ssh_key_path = os.path.join(os.path.expanduser("~/.ssh"),
self.ssh_key_path) self.ssh_key_path)
if not os.path.exists(self.ssh_key_path): if not os.path.exists(self.ssh_key_path):
raise BoxNotFound(f'Cannot find ssh public key: {self.key}') raise BoxConfError(f'Cannot find ssh public key: {self.key}')
def _set_defaults(self): def _set_defaults(self):
conf = yaml.safe_load(USER_DATA) conf = yaml.safe_load(USER_DATA)
@@ -534,6 +552,16 @@ class Config:
continue continue
setattr(self, key, str(val)) setattr(self, key, str(val))
# update distros dict with custom entry if there is at least image
if conf.get('boxpy_data') and conf['boxpy_data'].get('image'):
custom = {'username': conf['boxpy_data'].get('default_user'),
'realname': 'custom os',
'img_class': CustomImage,
'amd64': 'x86_64',
'image': conf['boxpy_data']['image'],
'default_version': '0'}
DISTROS['custom'] = custom
# remove boxpy_data since it will be not needed on the guest side # remove boxpy_data since it will be not needed on the guest side
if conf.get('boxpy_data'): if conf.get('boxpy_data'):
if conf['boxpy_data'].get('advanced'): if conf['boxpy_data'].get('advanced'):
@@ -559,6 +587,7 @@ class VBoxManage:
self.name_or_uuid = name_or_uuid self.name_or_uuid = name_or_uuid
self.vm_info = {} self.vm_info = {}
self.uuid = None self.uuid = None
self.running = False
def get_vm_base_path(self): def get_vm_base_path(self):
path = self._get_vm_config() path = self._get_vm_config()
@@ -610,6 +639,9 @@ class VBoxManage:
if line.startswith('Config file:'): if line.startswith('Config file:'):
self.vm_info['config_file'] = line.split('Config ' self.vm_info['config_file'] = line.split('Config '
'file:')[1].strip() 'file:')[1].strip()
if line.startswith('State:'):
self.running = line.split(':')[1].strip().startswith('running')
break break
dom = xml.dom.minidom.parse(self.vm_info['config_file']) dom = xml.dom.minidom.parse(self.vm_info['config_file'])
@@ -653,6 +685,9 @@ class VBoxManage:
def poweroff(self): def poweroff(self):
Run(['vboxmanage', 'controlvm', self.name_or_uuid, 'poweroff']) Run(['vboxmanage', 'controlvm', self.name_or_uuid, 'poweroff'])
def acpipowerbutton(self):
Run(['vboxmanage', 'controlvm', self.name_or_uuid, 'acpipowerbutton'])
def vmlist(self, only_running=False, long_list=False, only_boxpy=False): def vmlist(self, only_running=False, long_list=False, only_boxpy=False):
subcommand = 'runningvms' if only_running else 'vms' subcommand = 'runningvms' if only_running else 'vms'
machines = {} machines = {}
@@ -702,6 +737,12 @@ class VBoxManage:
LOG.fatal('Failed to create VM:\n%s', out.stderr) LOG.fatal('Failed to create VM:\n%s', out.stderr)
return None return None
if out.stdout.startswith('WARNING:'):
LOG.fatal('Created crippled VM:\n%s\nFix the issue with '
'VirtualBox, remove the dead VM and start over.',
out.stdout)
return None
for line in out.stdout.split('\n'): for line in out.stdout.split('\n'):
if line.startswith('UUID:'): if line.startswith('UUID:'):
self.uuid = line.split('UUID:')[1].strip() self.uuid = line.split('UUID:')[1].strip()
@@ -868,10 +909,10 @@ class Image:
URL = "" URL = ""
IMG = "" IMG = ""
def __init__(self, vbox, version, arch, release): def __init__(self, vbox, version, arch, release, fname=None):
self.vbox = vbox self.vbox = vbox
self._tmp = tempfile.mkdtemp(prefix='boxpy_') self._tmp = tempfile.mkdtemp(prefix='boxpy_')
self._img_fname = None self._img_fname = fname
def convert_to_vdi(self, disk_img, size): def convert_to_vdi(self, disk_img, size):
LOG.info('Converting and resizing "%s", new size: %s', disk_img, size) LOG.info('Converting and resizing "%s", new size: %s', disk_img, size)
@@ -950,7 +991,7 @@ class Ubuntu(Image):
URL = "https://cloud-images.ubuntu.com/releases/%s/release/%s" URL = "https://cloud-images.ubuntu.com/releases/%s/release/%s"
IMG = "ubuntu-%s-server-cloudimg-%s.img" IMG = "ubuntu-%s-server-cloudimg-%s.img"
def __init__(self, vbox, version, arch, release): def __init__(self, vbox, version, arch, release, fname=None):
super().__init__(vbox, version, arch, release) super().__init__(vbox, version, arch, release)
self._img_fname = self.IMG % (version, arch) self._img_fname = self.IMG % (version, arch)
self._img_url = self.URL % (version, self._img_fname) self._img_url = self.URL % (version, self._img_fname)
@@ -975,7 +1016,7 @@ class Fedora(Image):
IMG = "Fedora-Cloud-Base-%s-%s.%s.qcow2" IMG = "Fedora-Cloud-Base-%s-%s.%s.qcow2"
CHKS = "Fedora-Cloud-%s-%s-%s-CHECKSUM" CHKS = "Fedora-Cloud-%s-%s-%s-CHECKSUM"
def __init__(self, vbox, version, arch, release): def __init__(self, vbox, version, arch, release, fname=None):
super().__init__(vbox, version, arch, release) super().__init__(vbox, version, arch, release)
self._img_fname = self.IMG % (version, release, arch) self._img_fname = self.IMG % (version, release, arch)
self._img_url = self.URL % (version, arch, self._img_fname) self._img_url = self.URL % (version, arch, self._img_fname)
@@ -1012,12 +1053,13 @@ class CentosStream(Image):
self._img_url = self.URL % (version, arch, self._img_fname) self._img_url = self.URL % (version, arch, self._img_fname)
def _get_image_name(self, version, arch): def _get_image_name(self, version, arch):
Run(['wget', self._checksum_url, '-q', '-O', self._checksum_file]) fname = os.path.join(self._tmp, self._checksum_file)
Run(['wget', self._checksum_url, '-q', '-O', fname])
pat = re.compile(self.IMG % (version, arch)) pat = re.compile(self.IMG % (version, arch))
images = [] images = []
with open(self._checksum_file) as fobj: with open(fname) as fobj:
for line in fobj.read().strip().split('\n'): for line in fobj.read().strip().split('\n'):
line = line.strip() line = line.strip()
if line.startswith('#'): if line.startswith('#'):
@@ -1026,6 +1068,7 @@ class CentosStream(Image):
if match and match.groups(): if match and match.groups():
images.append(match.groups()[0]) images.append(match.groups()[0])
Run(['rm', fname])
images.reverse() images.reverse()
if images: if images:
return images[0] return images[0]
@@ -1044,6 +1087,13 @@ class CentosStream(Image):
return expected_sum return expected_sum
class CustomImage(Image):
def _download_image(self):
# just use provided image
return True
DISTROS = {'ubuntu': {'username': 'ubuntu', DISTROS = {'ubuntu': {'username': 'ubuntu',
'realname': 'ubuntu', 'realname': 'ubuntu',
'img_class': Ubuntu, 'img_class': Ubuntu,
@@ -1066,7 +1116,7 @@ def get_image_object(vbox, version, image='ubuntu', arch='amd64'):
if image == 'fedora': if image == 'fedora':
release = FEDORA_RELEASE_MAP[version] release = FEDORA_RELEASE_MAP[version]
return DISTROS[image]['img_class'](vbox, version, DISTROS[image]['amd64'], return DISTROS[image]['img_class'](vbox, version, DISTROS[image]['amd64'],
release) release, DISTROS[image].get('image'))
class IsoImage: class IsoImage:
@@ -1117,7 +1167,8 @@ def vmcreate(args, conf=None):
if not conf: if not conf:
try: try:
conf = Config(args) conf = Config(args)
except BoxNotFound: except BoxConfError as err:
LOG.fatal(f'Configuration error: {err.args[0]}.')
return 7 return 7
except yaml.YAMLError: except yaml.YAMLError:
LOG.fatal(f'Cannot read or parse file `{args.config}` as YAML ' LOG.fatal(f'Cannot read or parse file `{args.config}` as YAML '
@@ -1141,7 +1192,9 @@ def vmcreate(args, conf=None):
if not vbox.create_controller('SATA', 'sata'): if not vbox.create_controller('SATA', 'sata'):
return 4 return 4
for key in ('distro', 'hostname', 'key', 'version'): for key in ('distro', 'hostname', 'key', 'version', 'image'):
if not getattr(conf, key) is None:
continue
if not vbox.setextradata(key, getattr(conf, key)): if not vbox.setextradata(key, getattr(conf, key)):
return 5 return 5
@@ -1179,34 +1232,52 @@ def vmcreate(args, conf=None):
# than, let's try to see if boostraping process has finished # than, let's try to see if boostraping process has finished
LOG.info('Waiting for cloud init to finish ', end='') LOG.info('Waiting for cloud init to finish ', end='')
username = DISTROS[conf.distro]["username"]
cmd = ['ssh', '-o', 'StrictHostKeyChecking=no', cmd = ['ssh', '-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null', '-o', 'UserKnownHostsFile=/dev/null',
'-o', 'ConnectTimeout=2', '-o', 'ConnectTimeout=2',
'-i', conf.ssh_key_path[:-4], '-i', conf.ssh_key_path[:-4],
f'ssh://{DISTROS[conf.distro]["username"]}' f'ssh://{username}@localhost:{vbox.vm_info["port"]}',
f'@localhost:{vbox.vm_info["port"]}', 'sudo cloud-init status'] 'sudo cloud-init status']
try: try:
while True: while True:
out = Run(cmd).stdout out = Run(cmd)
LOG.debug('Out: %s', out) LOG.debug('Out: %s', out.stdout)
if (not out) or ('status' in out and 'running' in out): if (not out.stdout) or ('status' in out.stdout and
'running' in out.stdout):
LOG.info('.', end='') LOG.info('.', end='')
sys.stdout.flush() sys.stdout.flush()
if 'Permission denied (publickey)' in out.stderr:
if conf.username and conf.username != username:
username = conf.username
vbox.setextradata('username', username)
cmd[9] = (f'ssh://{username}'
f'@localhost:{vbox.vm_info["port"]}')
continue
raise PermissionError(f'There is an issue with accessing '
f'VM with ssh for user {username}. '
f'Check output in debug mode.')
time.sleep(3) time.sleep(3)
continue continue
LOG.info(' done') LOG.info(' done')
break break
out = out.split(':')[1].strip() out = out.stdout.split(':')[1].strip()
if out != 'done': if out != 'done':
cmd = cmd[:-1] cmd = cmd[:-1]
cmd.append('cloud-init status -l') cmd.append('cloud-init status -l')
LOG.warning('Cloud init finished with "%s" status:\n%s', out, LOG.warning('Cloud init finished with "%s" status:\n%s', out,
Run(cmd).stdout) Run(cmd).stdout)
except PermissionError:
LOG.info('\n')
iso.cleanup()
image.cleanup()
vbox.destroy()
raise
except KeyboardInterrupt: except KeyboardInterrupt:
LOG.warning('\nIterrupted, cleaning up') LOG.warning('\nInterrupted, cleaning up')
iso.cleanup() iso.cleanup()
image.cleanup() image.cleanup()
vbox.destroy() vbox.destroy()
@@ -1218,16 +1289,26 @@ def vmcreate(args, conf=None):
# reread config to update fields # reread config to update fields
conf = Config(args, vbox) conf = Config(args, vbox)
username = DISTROS[conf.distro]["username"]
LOG.info('You can access your VM by issuing:') LOG.info('You can access your VM by issuing:')
if conf.username and conf.username != username:
LOG.info(f'ssh -p {conf.port} -i {conf.ssh_key_path[:-4]} ' LOG.info(f'ssh -p {conf.port} -i {conf.ssh_key_path[:-4]} '
f'{DISTROS[conf.distro]["username"]}@localhost') f'{conf.username}@localhost')
else:
LOG.info(f'ssh -p {conf.port} -i {conf.ssh_key_path[:-4]} '
f'{username}@localhost')
LOG.info('or simply:') LOG.info('or simply:')
LOG.info(f'boxpy ssh {conf.name}') LOG.info(f'boxpy ssh {conf.name}')
return 0 return 0
def vmdestroy(args): def vmdestroy(args):
for name in args.name: if isinstance(args.name, list):
vm_names = args.name
else:
vm_names = [args.name]
for name in vm_names:
vbox = VBoxManage(name) vbox = VBoxManage(name)
if not vbox.get_vm_info(): if not vbox.get_vm_info():
LOG.fatal(f'Cannot remove VM "{name}" - it doesn\'t exists.') LOG.fatal(f'Cannot remove VM "{name}" - it doesn\'t exists.')
@@ -1332,7 +1413,8 @@ def vmrebuild(args):
try: try:
conf = Config(args, vbox) conf = Config(args, vbox)
except BoxNotFound: except BoxNotFound as ex:
LOG.fatal(f'Error with parsing config: {ex}')
return 8 return 8
except yaml.YAMLError: except yaml.YAMLError:
LOG.fatal(f'Cannot read or parse file `{args.config}` as YAML ' LOG.fatal(f'Cannot read or parse file `{args.config}` as YAML '
@@ -1380,13 +1462,43 @@ def connect(args):
f'file.') f'file.')
return 16 return 16
username = conf.username or DISTROS[conf.distro]["username"]
return Run(['ssh', '-o', 'StrictHostKeyChecking=no', return Run(['ssh', '-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null', '-o', 'UserKnownHostsFile=/dev/null',
'-i', conf.ssh_key_path[:-4], '-i', conf.ssh_key_path[:-4],
f'ssh://{DISTROS[conf.distro]["username"]}' f'ssh://{username}'
f'@localhost:{conf.port}'], False).returncode f'@localhost:{conf.port}'], False).returncode
def _set_vmstate(name, state):
vbox = VBoxManage(name)
if not vbox.get_vm_info():
LOG.fatal(f'No machine has been found with a name `{name}`.')
return 20
if vbox.running and state == "start":
LOG.info(f'VM "{name}" is already running.')
return
if not vbox.running and state == "stop":
LOG.info(f'VM "{name}" is already stopped.')
return
if state == "start":
vbox.poweron()
else:
vbox.acpipowerbutton()
def vmstart(args):
_set_vmstate(args.name, 'start')
def vmstop(args):
_set_vmstate(args.name, 'stop')
def main(): def main():
parser = argparse.ArgumentParser(description="Automate deployment and " parser = argparse.ArgumentParser(description="Automate deployment and "
"maintenance of VMs using cloud config," "maintenance of VMs using cloud config,"
@@ -1452,7 +1564,7 @@ def main():
help='show only running VMs') help='show only running VMs')
list_vms.set_defaults(func=vmlist) list_vms.set_defaults(func=vmlist)
rebuild = subparsers.add_parser('rebuild', help='Rebuild VM, all options ' rebuild = subparsers.add_parser('rebuild', help='rebuild VM, all options '
'besides vm name are optional, and their ' 'besides vm name are optional, and their '
'values will be taken from vm definition.') 'values will be taken from vm definition.')
rebuild.add_argument('name', help='name or UUID of the VM') rebuild.add_argument('name', help='name or UUID of the VM')
@@ -1481,13 +1593,21 @@ def main():
rebuild.add_argument('-v', '--version', help='distribution version') rebuild.add_argument('-v', '--version', help='distribution version')
rebuild.set_defaults(func=vmrebuild) rebuild.set_defaults(func=vmrebuild)
start = subparsers.add_parser('start', help='start VM')
start.add_argument('name', help='name or UUID of the VM')
start.set_defaults(func=vmstart)
stop = subparsers.add_parser('stop', help='stop VM')
stop.add_argument('name', help='name or UUID of the VM')
stop.set_defaults(func=vmstop)
completion = subparsers.add_parser('completion', help='generate shell ' completion = subparsers.add_parser('completion', help='generate shell '
'completion') 'completion')
completion.add_argument('shell', choices=['bash'], completion.add_argument('shell', choices=['bash'],
help="pick shell to generate completions for") help="pick shell to generate completions for")
completion.set_defaults(func=shell_completion) completion.set_defaults(func=shell_completion)
ssh = subparsers.add_parser('ssh', help='Connect to the machine via SSH') ssh = subparsers.add_parser('ssh', help='connect to the machine via SSH')
ssh.add_argument('name', help='name or UUID of the VM') ssh.add_argument('name', help='name or UUID of the VM')
ssh.set_defaults(func=connect) ssh.set_defaults(func=connect)