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:
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')