From b09bec7a70668165d4a3b53d9a8327f3f5994c63 Mon Sep 17 00:00:00 2001 From: Kumi Date: Fri, 29 Mar 2024 13:54:37 +0100 Subject: [PATCH] feat: start implementing VM editing and configuration Introduced a new set of Python classes under `classes/` directory, enabling functionality to edit and configure virtual machines (VMs) through a text-based user interface (TUI). Key components include handling for Linux distribution-specific configurations, partition mounting, and VM disk manipulation using QEMU tools. This structure allows for scalable VM management tasks such as user modification, network configuration, hostname setting, and disk conversion from VMDK to raw image format. Additionally, basic infrastructure such as `.gitignore`, `README.md`, and `requirements.txt` setup is included to ensure a clean development environment and provide necessary instructions and dependencies for running the script. The TUI, powered by npyscreen, facilitates user interaction for configuring newly cloned VMs, focusing on ease of use and automation of repetitive tasks. This comprehensive suite marks a pivotal step towards automating VM management and customization processes, significantly reducing manual overhead for system administrators and developers dealing with virtual environments. --- .gitignore | 5 +++ README.md | 1 + __init__.py | 0 classes/__init__.py | 0 classes/linux.py | 28 ++++++++++++ classes/mount.py | 50 +++++++++++++++++++++ classes/qemu.py | 91 ++++++++++++++++++++++++++++++++++++++ classes/tui.py | 104 ++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + run.py | 11 +++++ utils/fdisk.py | 30 +++++++++++++ 11 files changed, 321 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 classes/__init__.py create mode 100644 classes/linux.py create mode 100644 classes/mount.py create mode 100644 classes/qemu.py create mode 100644 classes/tui.py create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 utils/fdisk.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1c013d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +venv/ +*.pyc +__pycache__/ +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f771477 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +To use this script, you need the `qemu-img` tool, which is included with `qemu`. You also need `fdisk`, which is usually preinstalled anyway, as well as the `losetup` and `mount` utilities which should also come preinstalled. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/classes/__init__.py b/classes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/classes/linux.py b/classes/linux.py new file mode 100644 index 0000000..79cce79 --- /dev/null +++ b/classes/linux.py @@ -0,0 +1,28 @@ +from abc import ABC, abstractmethod + +class LinuxDistroHandler(ABC): + @abstractmethod + def list_modify_users(self, mount_point): + pass + + @abstractmethod + def configure_networking(self, mount_point): + pass + + @abstractmethod + def set_hostname(self, mount_point, hostname): + pass + +class GenericLinuxHandler(LinuxDistroHandler): + def list_modify_users(self, mount_point): + # TODO: List /etc/passwd contents and allow modifications + pass + + def configure_networking(self, mount_point): + # TODO: Allow network interface configuration + pass + + def set_hostname(self, mount_point, hostname): + # TODO: Set the system hostname + pass + diff --git a/classes/mount.py b/classes/mount.py new file mode 100644 index 0000000..0b07beb --- /dev/null +++ b/classes/mount.py @@ -0,0 +1,50 @@ +import subprocess +import tempfile +import os +import sys + +class Mountpoint(tempfile.TemporaryDirectory): + pass + + +class PartitionMounter: + def __init__(self, img_file, partition_number): + self.img_file = img_file + self.partition_number = partition_number + self.mountpoint = None + self.loop_device = None + + def setup_loop_device(self): + self.loop_device = subprocess.check_output(['losetup', '--show', '-fP', self.img_file], text=True).strip() + print(f"Loop device created: {self.loop_device}") + + def mount_partition(self): + if self.loop_device is None: + self.setup_loop_device() + + # Use partprobe to make the kernel aware of the partition + subprocess.run(['partprobe', self.loop_device], check=True) + + # Calculate partition path + partition_path = f"{self.loop_device}p{self.partition_number}" + + # Create a temporary directory to mount the partition + self.mountpoint = Mountpoint() + + # Mount the partition to the temporary directory + subprocess.run(['mount', partition_path, self.mountpoint.name], check=True) + + def cleanup(self): + if self.mountpoint: + subprocess.run(['umount', self.mountpoint.name], check=True) + self.mountpoint.cleanup() + + if self.loop_device: + subprocess.run(['losetup', '-d', self.loop_device], check=True) + + def __enter__(self): + self.mount_partition() + return self.mountpoint.name + + def __exit__(self, exc_type, exc_val, exc_tb): + self.cleanup() diff --git a/classes/qemu.py b/classes/qemu.py new file mode 100644 index 0000000..2481fb2 --- /dev/null +++ b/classes/qemu.py @@ -0,0 +1,91 @@ +from pathlib import Path +from typing import Tuple + +import os +import shutil +import subprocess + +import utils.fdisk + +from .mount import PartitionMounter + + +class VMEditor: + def __init__(self, source: os.PathLike, destination: os.PathLike): + self.source_path = Path(source) + self.destination_path = Path(destination) + self.system_partition: Tuple[str, int] = None + self.mount: PartitionMounter = None + + def create_copy(self): + shutil.copytree(self.source_path, self.destination_path) + + def update_filenames(self): + for file in self.destination_path.iterdir(): + if self.source_path.name in file.name: + file.rename( + self.destination_path + / file.name.replace( + self.source_path.name, self.destination_path.name + ) + ) + + def update_file_contents(self): + vmxpath = self.destination_path / f"{self.destination_path.name}.vmx" + content = "" + + with (vmxpath).open("r") as vmx_in: + content = vmx_in.read() + + content = content.replace(self.source_path.name, self.destination_path.name) + + with vmxpath.open("w") as vmx_out: + vmx_out.write(content) + + for vmdk in self.destination_path.glob("*.vmdk"): + if vmdk.stat().st_size < 1e5: + with vmdk.open("r") as vmdk_in: + content = vmdk_in.read() + + content = content.replace( + self.source_path.name, self.destination_path.name + ) + + with vmdk.open("w") as vmdk_out: + content = vmdk_out.write(content) + + def vmdk_to_img(self): + for vmdk in self.destination_path.glob("*.vmdk"): + if vmdk.stem.endswith("-flat"): + continue + + img = self.destination_path / f"{vmdk.stem}.img" + + subprocess.run( + [ + "qemu-img", + "convert", + "-O", + "raw", + str(vmdk), + str(img), + ], + check=True, + ) + + vmdk.unlink() + + if (flat := self.destination_path / f"{vmdk.stem}-flat.vmdk").exists(): + flat.unlink() + + def get_partitions(self): + files = [] + + for img in self.destination_path.glob("*.img"): + files += utils.fdisk.get_partitions(img) + + return files + + def mount_system_partition(self): + self.mount = PartitionMounter(*self.system_partition) + self.mount.mount_partition() diff --git a/classes/tui.py b/classes/tui.py new file mode 100644 index 0000000..07c0f27 --- /dev/null +++ b/classes/tui.py @@ -0,0 +1,104 @@ +import npyscreen + +import time +import sys + +from .qemu import VMEditor + + +class VMApp(npyscreen.NPSAppManaged): + editor: VMEditor = None + + def onStart(self): + self.addForm("MAIN", VMConfiguratorForm, name="VM Configurator") + self.addForm("PARTITION", PartitionSelectorForm, name="Partition Selector") + + +class VMConfiguratorForm(npyscreen.FormBaseNew): + def create(self): + self.sourceDir = self.add(npyscreen.TitleFilename, name="Source Directory:") + self.newName = self.add(npyscreen.TitleText, name="New Name:") + self.button = self.add( + npyscreen.ButtonPress, name="Start", when_pressed_function=self.on_start + ) + + def on_start(self): + source_dir = self.sourceDir.value + new_name = self.newName.value + + if npyscreen.notify_ok_cancel( + f"Directory: {source_dir}\nNew Name: {new_name}", + title="Info", + form_color="CONTROL", + ): + self.process() + + def process(self): + self.parentApp.editor = VMEditor(self.sourceDir.value, self.newName.value) + + message = "Processing:\n\n" + + npyscreen.notify_wait( + message := message + "- Copying files\n", title="Please wait..." + ) + # self.parentApp.editor.create_copy() + + npyscreen.notify_wait( + message := message + "- Updating file names\n", title="Please wait..." + ) + # self.parentApp.editor.update_filenames() + + npyscreen.notify_wait( + message := message + "- Updating configuration file\n", + title="Please wait...", + ) + # self.parentApp.editor.update_file_contents() + + npyscreen.notify_wait( + message := message + "- Converting disk to .img\n", title="Please wait..." + ) + # self.parentApp.editor.vmdk_to_img() + + npyscreen.notify_wait( + message + "- Preparing partition selector", title="Please wait..." + ) + + self.parentApp.switchForm("PARTITION") + + +class PartitionSelectorForm(npyscreen.FormBaseNew): + def create(self): + self.partition = self.add(npyscreen.SelectOne, values=[], max_height=10) + self.button = self.add( + npyscreen.ButtonPress, name="Select", when_pressed_function=self.on_select + ) + + def pre_edit_loop(self): + self.partition.values = [ + f'{partition["device"]} – {partition["size"]} ({partition["type"]}{partition["boot"]})' + for partition in self.parentApp.editor.get_partitions() + ] + + def on_select(self): + partition = self.partition.values[self.partition.value[0]] + + path = partition.split(" – ")[0] + + file = ".img".join(path.split(".img")[:-1]) + ".img" + number = path.split(".img")[-1] + + message = "Processing:\n\n" + + npyscreen.notify_wait( + message := message + "- Mounting system partition\n", title="Please wait..." + ) + + self.parentApp.editor.system_partition = (file, number) + self.parentApp.editor.mount_system_partition() + + if npyscreen.notify_ok_cancel( + f"Mountpoint: {self.parentApp.editor.mount.mountpoint.name}", + title="Info", + form_color="CONTROL", + ): + sys.exit() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a889ad6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +npyscreen diff --git a/run.py b/run.py new file mode 100644 index 0000000..1e96c68 --- /dev/null +++ b/run.py @@ -0,0 +1,11 @@ +from classes.tui import VMApp + +def main(): + try: + app = VMApp() + app.run() + except KeyboardInterrupt: + pass + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/utils/fdisk.py b/utils/fdisk.py new file mode 100644 index 0000000..f76aa17 --- /dev/null +++ b/utils/fdisk.py @@ -0,0 +1,30 @@ +import subprocess + + +def get_partitions(image_file): + result = subprocess.run( + ["fdisk", "-l", image_file], stdout=subprocess.PIPE, text=True + ) + output = result.stdout + + headers = [] + + for line in output.splitlines(): + if line.startswith("Device"): + headers = line.split() + + partitions = [] + for line in output.splitlines(): + if line.startswith(str(image_file)): + parts = line.split() + boot_flag = "*" if "*" in parts else "" + corr = int(bool(boot_flag)) - 1 + partition_info = { + "device": parts[0], + "boot": boot_flag, + "size": parts[headers.index("Size") + corr], + "type": " ".join(parts[headers.index("Type") + corr :]), + } + partitions.append(partition_info) + + return partitions