aetherscale

[unmaintained] code for a cloud provider tutorial
Log | Files | Refs | README | LICENSE

commit 720ddbbce85e1c2cccc202ec5c8f6bc4094c9937
parent 50966c72f67b0834f57a381f2663e266d2352f94
Author: Stefan Koch <programming@stefan-koch.name>
Date:   Sun, 13 Dec 2020 18:09:23 +0100

create an init script on the VM

Diffstat:
Maetherscale/client.py | 8++++++++
Maetherscale/computing.py | 16++++++++++------
Aaetherscale/qemu/image.py | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Raetherscale/qemu.py -> aetherscale/qemu/runtime.py | 0
Aaetherscale/timing.py | 17+++++++++++++++++
Msetup.py | 5++++-
Mtests/conftest.py | 21++++-----------------
Mtests/test_qemu.py | 3+--
Atests/test_qemu_image.py | 18++++++++++++++++++
9 files changed, 149 insertions(+), 26 deletions(-)

diff --git a/aetherscale/client.py b/aetherscale/client.py @@ -65,6 +65,9 @@ def main(): create_vm_parser = subparsers.add_parser('create-vm') create_vm_parser.add_argument( '--image', help='Name of the image to create a VM from', required=True) + create_vm_parser.add_argument( + '--init-script', dest='init_script_path', + help='Script to execute at first boot of VM', required=False) start_vm_parser = subparsers.add_parser('start-vm') start_vm_parser.add_argument( '--vm-id', dest='vm_id', help='ID of the VM to start', required=True) @@ -88,12 +91,17 @@ def main(): } elif args.subparser_name == 'create-vm': response_expected = True + data = { 'command': 'create-vm', 'options': { 'image': args.image, } } + + if args.init_script_path: + with open(args.init_script_path, 'rt') as f: + data['options']['init-script'] = f.read() elif args.subparser_name == 'stop-vm': response_expected = True data = { diff --git a/aetherscale/computing.py b/aetherscale/computing.py @@ -16,7 +16,7 @@ from typing import List, Optional, Dict, Any, Callable from . import interfaces from . import execution -from . import qemu +from .qemu import image, runtime from .config import LOG_LEVEL, RABBITMQ_HOST @@ -59,7 +59,7 @@ def create_user_image(vm_id: str, image_name: str) -> Path: 'qemu-img', 'create', '-f', 'qcow2', '-b', str(base_image.absolute()), '-F', 'qcow2', str(user_image)]) if create_img_result.returncode != 0: - raise qemu.QemuException(f'Could not create image for VM "{vm_id}"') + raise runtime.QemuException(f'Could not create image for VM "{vm_id}"') return user_image @@ -75,9 +75,9 @@ def list_vms(_: Dict[str, Any]) -> List[Dict[str, Any]]: hint = None ip_addresses = [] try: - fetcher = qemu.GuestAgentIpAddress(socket_file) + fetcher = runtime.GuestAgentIpAddress(socket_file) ip_addresses = fetcher.fetch_ip_addresses() - except qemu.QemuException: + except runtime.QemuException: hint = 'Could not retrieve IP address for guest' msg = { @@ -104,9 +104,13 @@ def create_vm(options: Dict[str, Any]) -> Dict[str, str]: try: user_image = create_user_image(vm_id, image_name) - except (OSError, qemu.QemuException): + except (OSError, runtime.QemuException): raise + if 'init-script' in options: + with image.guestmount(user_image) as guest_fs: + image.install_startup_script(options['init-script'], guest_fs) + mac_addr = interfaces.create_mac_address() logging.debug(f'Assigning MAC address "{mac_addr}" to VM "{vm_id}"') @@ -180,7 +184,7 @@ def stop_vm(options: Dict[str, Any]) -> Dict[str, str]: execution.stop_systemd_unit(unit_name) else: qemu_socket = qemu_socket_monitor(vm_id) - qm = qemu.QemuMonitor(qemu_socket, protocol=qemu.QemuProtocol.QMP) + qm = runtime.QemuMonitor(qemu_socket, protocol=qemu.QemuProtocol.QMP) qm.execute('system_powerdown') response = { diff --git a/aetherscale/qemu/image.py b/aetherscale/qemu/image.py @@ -0,0 +1,87 @@ +from contextlib import contextmanager +import logging +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +from typing import List, TextIO, Iterator + +from aetherscale.execution import run_command_chain +import aetherscale.timing + + +STARTUP_FILENAME = 'aetherscale-init' + + +@contextmanager +def guestmount(image_path: Path) -> Iterator[Path]: + mount_dir = tempfile.mkdtemp() + + logging.debug(f'Mounting {image_path} at {mount_dir}') + try: + run_command_chain([ + ['guestmount', '-a', str(image_path.absolute()), '-i', mount_dir]]) + yield Path(mount_dir) + finally: + logging.debug(f'Unmounting {mount_dir}') + run_command_chain([['guestunmount', mount_dir]]) + os.rmdir(mount_dir) + + # It seems image is not released immediately after guestunmount returns + # thus we have to wait until write-lock is released, but at most k + # seconds + with aetherscale.timing.timeout(seconds=5): + logging.debug(f'Waiting for write lock to get released') + + access_ok = False + while not access_ok: + # qemu-img info fails if write lock cannot be retrieved + result = subprocess.run(['qemu-img', 'info', str(image_path)]) + access_ok = result.returncode == 0 + + +def install_startup_script(script_source: str, mount_dir: Path): + startup_service_path = \ + mount_dir / f'etc/systemd/system/{STARTUP_FILENAME}.service' + with open(startup_service_path, 'wt') as f: + create_systemd_startup_unit(f, Path(f'/root/{STARTUP_FILENAME}.sh')) + + multi_user_target_path = \ + mount_dir / f'etc/systemd/system/multi-user.target.wants' + multi_user_target_path.mkdir(parents=True, exist_ok=True) + + with tempfile.NamedTemporaryFile('wt') as startup_script: + startup_script.write(script_source) + startup_script.flush() + + executable_target = mount_dir / f'root/{STARTUP_FILENAME}.sh' + shutil.copyfile(startup_script.name, executable_target) + os.symlink( + f'/etc/systemd/system/{STARTUP_FILENAME}.service', + multi_user_target_path / f'{STARTUP_FILENAME}.service') + + os.chmod(executable_target, 0o755) + + +def create_systemd_startup_unit( + f: TextIO, startup_script: Path): + # TODO: init-script must only be run on first boot e.g. with Conditions + # and a depending unit could write a marker file + logging.debug(f'Creating systemd init-script service at {startup_script}') + + condition_file = f'/root/{STARTUP_FILENAME}.done' + + f.write('[Unit]\n') + f.write('Description=aetherscale VM init script\n') + f.write(f'ConditionPathExists=!{condition_file}\n') + f.write('\n') + f.write('[Service]\n') + f.write('Type=oneshot\n') + # minus means: always execute the second command, independent from success + # state of first script + f.write(f'ExecStart=-{str(startup_script)}\n') + f.write(f'ExecStart=/bin/touch {condition_file}\n') + f.write('\n') + f.write('[Install]\n') + f.write('WantedBy=multi-user.target\n') diff --git a/aetherscale/qemu.py b/aetherscale/qemu/runtime.py diff --git a/aetherscale/timing.py b/aetherscale/timing.py @@ -0,0 +1,17 @@ +from contextlib import contextmanager +import signal + + +@contextmanager +def timeout(seconds: int): + def raise_exception(signum, frame): + raise TimeoutError + + """Run a block of code with a specified timeout. If the block is not + finished after the defined time, raise an exception.""" + try: + signal.signal(signal.SIGALRM, raise_exception) + signal.alarm(seconds) + yield None + finally: + signal.signal(signal.SIGALRM, signal.SIG_IGN) diff --git a/setup.py b/setup.py @@ -16,7 +16,10 @@ install_requires = [ setup( name='aetherscale', - packages=['aetherscale'], + packages=[ + 'aetherscale', + 'aetherscale.qemu', + ], entry_points={ 'console_scripts': [ 'aetherscale=aetherscale.server:main', diff --git a/tests/conftest.py b/tests/conftest.py @@ -1,22 +1,9 @@ -from contextlib import contextmanager +from pathlib import Path import pytest -import signal + +import aetherscale.timing @pytest.fixture def timeout(): - """Run a block of code with a specified timeout. If the block is not - finished after the defined time, raise an exception.""" - def raise_exception(signum, frame): - raise TimeoutError - - @contextmanager - def timeout_function(seconds: int): - try: - signal.signal(signal.SIGALRM, raise_exception) - signal.alarm(seconds) - yield None - finally: - signal.signal(signal.SIGALRM, signal.SIG_IGN) - - return timeout_function + return aetherscale.timing.timeout diff --git a/tests/test_qemu.py b/tests/test_qemu.py @@ -2,13 +2,12 @@ import contextlib import json from pathlib import Path import pytest -import signal import socket import tempfile import threading import uuid -from aetherscale.qemu import QemuMonitor, QemuProtocol +from aetherscale.qemu.runtime import QemuMonitor, QemuProtocol class MockQemuServer: diff --git a/tests/test_qemu_image.py b/tests/test_qemu_image.py @@ -0,0 +1,18 @@ +import os +from pathlib import Path + +from aetherscale.qemu.image import install_startup_script, STARTUP_FILENAME + + +def test_copies_startup_script_to_vm_dir(tmpdir): + tmpdir = Path(tmpdir) + + # Create directories that normally exist in mounted OS + (tmpdir / 'etc/systemd/system').mkdir(parents=True, exist_ok=True) + (tmpdir / 'root').mkdir(parents=True, exist_ok=True) + + install_startup_script('echo something', tmpdir) + + assert os.path.isfile(tmpdir / f'root/{STARTUP_FILENAME}.sh') + assert os.path.isfile( + tmpdir / f'etc/systemd/system/{STARTUP_FILENAME}.service')