From 128154c7d53ea77c665f38c014479de955743b2a Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Fri, 21 Mar 2025 21:59:13 +0200 Subject: [PATCH] initial iscsi Signed-off-by: Benny Zlotnik wip Signed-off-by: Benny Zlotnik ruff Signed-off-by: Benny Zlotnik address coderabbit comments Signed-off-by: Benny Zlotnik add docs Signed-off-by: Benny Zlotnik address comments Signed-off-by: Benny Zlotnik --- .../reference/package-apis/drivers/index.md | 2 + .../reference/package-apis/drivers/iscsi.md | 62 +++ packages/jumpstarter-driver-iscsi/README.md | 62 +++ .../examples/exporter.yaml | 16 + .../examples/iscsi.py | 125 ++++++ .../jumpstarter_driver_iscsi/__init__.py | 0 .../jumpstarter_driver_iscsi/client.py | 193 +++++++++ .../jumpstarter_driver_iscsi/driver.py | 369 ++++++++++++++++++ .../jumpstarter_driver_iscsi/py.typed | 0 .../jumpstarter-driver-iscsi/pyproject.toml | 38 ++ pyproject.toml | 1 + 11 files changed, 868 insertions(+) create mode 100644 docs/source/reference/package-apis/drivers/iscsi.md create mode 100644 packages/jumpstarter-driver-iscsi/README.md create mode 100644 packages/jumpstarter-driver-iscsi/examples/exporter.yaml create mode 100644 packages/jumpstarter-driver-iscsi/examples/iscsi.py create mode 100644 packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/__init__.py create mode 100644 packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/client.py create mode 100644 packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py create mode 100644 packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/py.typed create mode 100644 packages/jumpstarter-driver-iscsi/pyproject.toml diff --git a/docs/source/reference/package-apis/drivers/index.md b/docs/source/reference/package-apis/drivers/index.md index c654ccb5f..5bbc2213e 100644 --- a/docs/source/reference/package-apis/drivers/index.md +++ b/docs/source/reference/package-apis/drivers/index.md @@ -50,6 +50,7 @@ Drivers that control storage devices and manage data: Layer * **[SD Wire](sdwire.md)** (`jumpstarter-driver-sdwire`) - SD card switching utilities +* **[iSCSI](iscsi.md)** (`jumpstarter-driver-iscsi`) - iSCSI server to serve LUNs ### Media Drivers @@ -88,6 +89,7 @@ energenie.md flashers.md http.md http-power.md +iscsi.md network.md opendal.md power.md diff --git a/docs/source/reference/package-apis/drivers/iscsi.md b/docs/source/reference/package-apis/drivers/iscsi.md new file mode 100644 index 000000000..e40b1780f --- /dev/null +++ b/docs/source/reference/package-apis/drivers/iscsi.md @@ -0,0 +1,62 @@ +# iSCSI server driver + +`jumpstarter-driver-iscsi` provides a lightweight iSCSI **target** implementation powered by the Linux +[RFC-tgt](https://github.com/open-iscsi/tcmu-runner/) framework via the +[`rtslib-fb`](https://github.com/open-iscsi/rtslib-fb) Python bindings. + +> ⚠️ The driver **creates and manages an iSCSI _target_** (server). To access the +> exported LUNs you still need a separate iSCSI **initiator** (client) on the +> machine running your test-code / DUT. + +--- + +## Installation + +`rtslib-fb` relies on the in-kernel LIO target framework which is packaged +differently by each distribution. **You should be able to run `sudo targetcli` +without errors before you start the Jumpstarter driver.** + +Fedora: + +```{code-block} console +$ sudo dnf install targetcli python3-rtslib +``` + +Finally, install the driver itself from the Jumpstarter package index: + +```{code-block} console +:substitutions: +$ pip3 install --extra-index-url {{index_url}} jumpstarter-driver-iscsi +``` + +## Configuration + +The driver is configured through the exporter YAML file. A minimal example +exports the local file `disk.img` as a 5 GiB LUN: + +```yaml +export: + iscsi: + type: jumpstarter_driver_iscsi.driver.ISCSI + config: + root_dir: "/var/lib/iscsi" + target_name: "demo" + # When size_mb is 0 a pre-existing file size is used. +``` + +### Config parameters + +| Parameter | Description | Type | Required | Default | +| ----------- | ---------------------------------------------------------------------------- | ---- | -------- | --------------------------------- | +| `root_dir` | Directory where image files will be stored. | str | no | `/var/lib/iscsi` | +| `iqn_prefix`| IQN prefix to use when building the target IQN. | str | no | `iqn.2024-06.dev.jumpstarter` | +| `target_name`| The target name appended to the IQN prefix. | str | no | `target1` | +| `host` | IP address to bind the target to. Empty string will auto-detect default IP. | str | no | *auto* | +| `port` | TCP port the target listens on. | int | no | `3260` | + +## API Reference + +```{eval-rst} +.. autoclass:: jumpstarter_driver_iscsi.client.ISCSIServerClient() + :members: start, stop, get_host, get_port, get_target_iqn, add_lun, remove_lun, list_luns, upload_image +``` diff --git a/packages/jumpstarter-driver-iscsi/README.md b/packages/jumpstarter-driver-iscsi/README.md new file mode 100644 index 000000000..e40b1780f --- /dev/null +++ b/packages/jumpstarter-driver-iscsi/README.md @@ -0,0 +1,62 @@ +# iSCSI server driver + +`jumpstarter-driver-iscsi` provides a lightweight iSCSI **target** implementation powered by the Linux +[RFC-tgt](https://github.com/open-iscsi/tcmu-runner/) framework via the +[`rtslib-fb`](https://github.com/open-iscsi/rtslib-fb) Python bindings. + +> ⚠️ The driver **creates and manages an iSCSI _target_** (server). To access the +> exported LUNs you still need a separate iSCSI **initiator** (client) on the +> machine running your test-code / DUT. + +--- + +## Installation + +`rtslib-fb` relies on the in-kernel LIO target framework which is packaged +differently by each distribution. **You should be able to run `sudo targetcli` +without errors before you start the Jumpstarter driver.** + +Fedora: + +```{code-block} console +$ sudo dnf install targetcli python3-rtslib +``` + +Finally, install the driver itself from the Jumpstarter package index: + +```{code-block} console +:substitutions: +$ pip3 install --extra-index-url {{index_url}} jumpstarter-driver-iscsi +``` + +## Configuration + +The driver is configured through the exporter YAML file. A minimal example +exports the local file `disk.img` as a 5 GiB LUN: + +```yaml +export: + iscsi: + type: jumpstarter_driver_iscsi.driver.ISCSI + config: + root_dir: "/var/lib/iscsi" + target_name: "demo" + # When size_mb is 0 a pre-existing file size is used. +``` + +### Config parameters + +| Parameter | Description | Type | Required | Default | +| ----------- | ---------------------------------------------------------------------------- | ---- | -------- | --------------------------------- | +| `root_dir` | Directory where image files will be stored. | str | no | `/var/lib/iscsi` | +| `iqn_prefix`| IQN prefix to use when building the target IQN. | str | no | `iqn.2024-06.dev.jumpstarter` | +| `target_name`| The target name appended to the IQN prefix. | str | no | `target1` | +| `host` | IP address to bind the target to. Empty string will auto-detect default IP. | str | no | *auto* | +| `port` | TCP port the target listens on. | int | no | `3260` | + +## API Reference + +```{eval-rst} +.. autoclass:: jumpstarter_driver_iscsi.client.ISCSIServerClient() + :members: start, stop, get_host, get_port, get_target_iqn, add_lun, remove_lun, list_luns, upload_image +``` diff --git a/packages/jumpstarter-driver-iscsi/examples/exporter.yaml b/packages/jumpstarter-driver-iscsi/examples/exporter.yaml new file mode 100644 index 000000000..35e9af07a --- /dev/null +++ b/packages/jumpstarter-driver-iscsi/examples/exporter.yaml @@ -0,0 +1,16 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + namespace: default + name: iscsi-exporter +endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082 +token: "" +export: + iscsi: + type: jumpstarter_driver_iscsi.driver.ISCSI + config: + root_dir: "/var/lib/iscsi" + iqn_prefix: "iqn.2024-06.dev.jumpstarter" + target_name: "my-target" + host: "" + port: 3260 \ No newline at end of file diff --git a/packages/jumpstarter-driver-iscsi/examples/iscsi.py b/packages/jumpstarter-driver-iscsi/examples/iscsi.py new file mode 100644 index 000000000..1a1040a14 --- /dev/null +++ b/packages/jumpstarter-driver-iscsi/examples/iscsi.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python + +import os + +import click +from jumpstarter_driver_opendal.client import operator_for_path + +from jumpstarter.common.utils import env + + +def determine_architecture(arch, image): + """Determine target architecture from parameter or auto-detect""" + if arch != "auto": + return arch + + import platform + + if "aarch64" in image.lower() or "arm64" in image.lower(): + return "aarch64" + if "x86_64" in image.lower() or "amd64" in image.lower(): + return "x86_64" + + system_arch = platform.machine() + return "aarch64" if system_arch in ["aarch64", "arm64"] else "x86_64" + + +def handle_file_storage(image, location, lun_name, storage): + """Handle file storage setup and return target path and block device flag""" + is_block_device = False + + if location and location.startswith("/dev/"): + is_block_device = True + click.secho(f"Using block device: {location}", fg="blue") + if not click.confirm( + f"Are you sure you want to write to block device {location}? This will overwrite all data!", + default=False, + ): + raise click.Abort() + + device_path, fs_operator, _ = operator_for_path(location) + click.secho("Writing image to block device...", fg="blue") + storage.write_from_path(str(device_path), image, operator=fs_operator) + target_path = str(device_path) + else: + target_path = location if location else f"{lun_name}.img" + click.secho(f"Using storage path: {target_path}", fg="blue") + storage.write_from_path(target_path, image) + + return target_path, is_block_device + + +def generate_qemu_command(target_arch, host, port, target_iqn): + """Generate QEMU command based on architecture""" + if target_arch == "aarch64": + return f"qemu-system-aarch64 -m 2048 -machine virt -cpu cortex-a72 -drive file=iscsi://{host}:{port}/{target_iqn}/0,format=raw" + + return f"qemu-system-x86_64 -m 2048 -drive file=iscsi://{host}:{port}/{target_iqn}/0,format=raw" + + +@click.command() +@click.option("--image", required=True, help="Path to the bootable disk image to serve") +@click.option("--location", default=None, help="Where to store the image (file path or block device)") +@click.option("--lun-name", default="boot", help="Name for the LUN") +@click.option( + "--arch", + type=click.Choice(["x86_64", "aarch64", "auto"], case_sensitive=False), + default="auto", + help="Target architecture (auto-detect if not specified)", +) +def main(image, location, lun_name, arch): + if not os.path.exists(image): + click.secho(f"Error: Image '{image}' not found!", fg="red") + return + + file_size_bytes = os.path.getsize(image) + file_size_mb = file_size_bytes // (1024 * 1024) + if file_size_mb == 0 and file_size_bytes > 0: + file_size_mb = 1 + + with env() as client: + iscsi = client.iscsi + storage = iscsi.storage + + click.secho("iSCSI Bootable Disk Server", fg="green") + click.secho(f"Using image: {image} ({file_size_mb}MB)", fg="blue") + + click.secho("Starting iSCSI server", fg="blue") + iscsi.start() + + target_path, is_block_device = handle_file_storage(image, location, lun_name, storage) + + click.secho(f"Creating LUN '{lun_name}' with size {file_size_mb}MB", fg="blue") + iscsi.add_lun(lun_name, target_path, size_mb=file_size_mb, is_block=is_block_device) + + host = iscsi.get_host() + port = iscsi.get_port() + target_iqn = iscsi.get_target_iqn() + + click.secho(f"\niSCSI server running at {host}:{port}", fg="green") + click.secho(f"Target IQN: {target_iqn}", fg="green") + + luns = iscsi.list_luns() + click.secho("\nAvailable LUNs:", fg="yellow") + for lun in luns: + click.secho(f" - {lun['name']} ({lun['size'] / (1024 * 1024):.1f}MB)", fg="white") + + click.secho("\nBoot with QEMU:", fg="yellow") + target_arch = determine_architecture(arch, image) + qemu_cmd = generate_qemu_command(target_arch, host, port, target_iqn) + + click.secho(f"Architecture: {target_arch}", fg="cyan") + click.secho(qemu_cmd, fg="white") + + click.pause("\nPress any key to stop the server...") + + click.secho(f"Removing LUN '{lun_name}'", fg="blue") + iscsi.remove_lun(lun_name) + + click.secho("Stopping iSCSI server", fg="blue") + iscsi.stop() + click.secho("iSCSI server stopped", fg="green") + + +if __name__ == "__main__": + main() diff --git a/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/__init__.py b/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/client.py b/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/client.py new file mode 100644 index 000000000..548226e6a --- /dev/null +++ b/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/client.py @@ -0,0 +1,193 @@ +import hashlib +import os +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from jumpstarter_driver_composite.client import CompositeClient +from jumpstarter_driver_opendal.common import PathBuf +from opendal import Operator + + +@dataclass(kw_only=True) +class ISCSIServerClient(CompositeClient): + """ + Client interface for iSCSI Server driver + + This client provides methods to control an iSCSI target server and manage LUNs. + Supports exposing files and block devices through the iSCSI protocol. + """ + + def start(self): + """ + Start the iSCSI target server + + Initializes and starts the iSCSI target server if it's not already running. + The server will listen on the configured host and port. + """ + self.call("start") + + def stop(self): + """ + Stop the iSCSI target server + + Stops the running iSCSI target server and releases associated resources. + + Raises: + ISCSIError: If the server fails to stop + """ + self.call("stop") + + def get_host(self) -> str: + """ + Get the host address the iSCSI server is listening on + + Returns: + str: The IP address or hostname the server is bound to + """ + return self.call("get_host") + + def get_port(self) -> int: + """ + Get the port number the iSCSI server is listening on + + Returns: + int: The port number (default is 3260) + """ + return self.call("get_port") + + def get_target_iqn(self) -> str: + """ + Get the IQN of the target + + Returns: + str: The IQN string for connecting to this target + """ + return self.call("get_target_iqn") + + def add_lun(self, name: str, file_path: str, size_mb: int = 0, is_block: bool = False) -> str: + """ + Add a new LUN to the iSCSI target + + Args: + name (str): Unique name for the LUN + file_path (str): Path to the file or block device + size_mb (int): Size in MB for new file (if file doesn't exist), 0 means use existing file + is_block (bool): If True, the path is treated as a block device + + Returns: + str: Name of the created LUN + + Raises: + ISCSIError: If the LUN cannot be created + """ + return self.call("add_lun", name, file_path, size_mb, is_block) + + def remove_lun(self, name: str): + """ + Remove a LUN from the iSCSI target + + Args: + name (str): Name of the LUN to remove + + Raises: + ISCSIError: If the LUN cannot be removed + """ + self.call("remove_lun", name) + + def list_luns(self) -> List[Dict[str, Any]]: + """ + List all configured LUNs + + Returns: + List[Dict[str, Any]]: List of dictionaries with LUN information + """ + return self.call("list_luns") + + def _calculate_file_hash(self, file_path: str, operator: Optional[Operator] = None) -> str: + """Calculate SHA256 hash of a file""" + if operator is None: + hash_obj = hashlib.sha256() + with open(file_path, "rb") as f: + while chunk := f.read(8192): + hash_obj.update(chunk) + return hash_obj.hexdigest() + else: + from jumpstarter_driver_opendal.client import operator_for_path + + path, op, _ = operator_for_path(file_path) + hash_obj = hashlib.sha256() + with op.open(str(path), "rb") as f: + while chunk := f.read(8192): + hash_obj.update(chunk) + return hash_obj.hexdigest() + + def _files_are_identical(self, src: PathBuf, dst_path: str, operator: Optional[Operator] = None) -> bool: + """Check if source and destination files are identical""" + try: + if not self.storage.exists(dst_path): + return False + + dst_stat = self.storage.stat(dst_path) + dst_size = dst_stat.content_length + + if operator is None: + src_size = os.path.getsize(str(src)) + else: + from jumpstarter_driver_opendal.client import operator_for_path + + path, op, _ = operator_for_path(src) + src_size = op.stat(str(path)).content_length + + if src_size != dst_size: + return False + + src_hash = self._calculate_file_hash(str(src), operator) + dst_hash = self.storage.hash(dst_path, "sha256") + + return src_hash == dst_hash + + except Exception: + return False + + def upload_image( + self, + dst_name: str, + src: PathBuf, + size_mb: int = 0, + operator: Optional[Operator] = None, + force_upload: bool = False, + ) -> str: + """ + Upload an image file and expose it as a LUN + + Args: + dst_name (str): Name to use for the LUN and local filename + src (PathBuf): Source file path to read from + size_mb (int): Size in MB if creating a new image. If 0 will use source file size. + operator (Operator): Optional OpenDAL operator to use for reading + force_upload (bool): If True, skip file comparison and force upload + + Returns: + str: Target IQN for connecting to the LUN + + Raises: + ISCSIError: If the operation fails + """ + size_mb = int(size_mb) + dst_path = f"{dst_name}.img" + + if not force_upload and self._files_are_identical(src, dst_path, operator): + print(f"File {dst_path} already exists and is identical to source. Skipping upload.") + else: + print(f"Uploading {src} to {dst_path}...") + self.storage.write_from_path(dst_path, src, operator) + + if size_mb <= 0: + src_path = os.path.join(self.storage._storage.root_dir, dst_path) + size_mb = os.path.getsize(src_path) // (1024 * 1024) + if size_mb <= 0: + size_mb = 1 + + self.add_lun(dst_name, dst_path, size_mb) + + return self.get_target_iqn() diff --git a/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py b/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py new file mode 100644 index 000000000..f74183de1 --- /dev/null +++ b/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/driver.py @@ -0,0 +1,369 @@ +import os +import socket +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from jumpstarter_driver_opendal.driver import Opendal +from rtslib_fb import LUN, TPG, BlockStorageObject, FileIOStorageObject, NetworkPortal, RTSRoot, Target + +from jumpstarter.driver import Driver, export + + +class ISCSIError(Exception): + """Base exception for iSCSI server errors""" + + pass + + +class ConfigurationError(ISCSIError): + """Error in iSCSI configuration""" + + pass + + +class StorageObjectError(ISCSIError): + """Error related to storage objects""" + + pass + + +@dataclass(kw_only=True) +class ISCSI(Driver): + """iSCSI Target driver for Jumpstarter + + This driver implements an iSCSI target server that can expose files or block devices. + + Attributes: + root_dir (str): Root directory for the iSCSI storage + iqn_prefix (str): iqn prefix + target_name (str): Target name. Defaults to "target1" + host (str): IP address to bind the server to + port (int): Port number to listen on. Defaults to 3260 + """ + + root_dir: str = "/var/lib/iscsi" + iqn_prefix: str = "iqn.2024-06.dev.jumpstarter" + target_name: str = "target1" + host: str = field(default="") + port: int = 3260 + + _rtsroot: Optional[RTSRoot] = field(init=False, default=None) + _target: Optional[Target] = field(init=False, default=None) + _tpg: Optional[TPG] = field(init=False, default=None) + _storage_objects: Dict[str, Any] = field(init=False, default_factory=dict) + _portals: List[NetworkPortal] = field(init=False, default_factory=list) + _luns: Dict[str, LUN] = field(init=False, default_factory=dict) + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + + os.makedirs(self.root_dir, exist_ok=True) + + self.children["storage"] = Opendal(scheme="fs", kwargs={"root": self.root_dir}) + self.storage = self.children["storage"] + + if self.host == "": + self.host = self.get_default_ip() + + self._iqn = f"{self.iqn_prefix}:{self.target_name}" + + def get_default_ip(self): + """Get the IP address of the default route interface""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except Exception: + self.logger.warning("Could not determine default IP address, falling back to 0.0.0.0") + return "0.0.0.0" + + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_iscsi.client.ISCSIServerClient" + + def _configure_target(self): + """Helper that configures the target; formerly a context-manager but the + implicit enter/exit semantics were confusing and the driver never + needed teardown at this point. + """ + try: + self._rtsroot = RTSRoot() + + self._setup_target() + self._setup_network_portal() + self._configure_tpg_attributes() + except Exception as e: + self.logger.error(f"Error in iSCSI target configuration: {e}") + raise + + def _setup_target(self): + """Setup the iSCSI target""" + target_exists = False + try: + targets_list = list(self._rtsroot.targets) # type: ignore[attr-defined] + for target in targets_list: + if target.wwn == self._iqn: + self._target = target + target_exists = True + self.logger.info(f"Using existing target: {self._iqn}") + if target.tpgs: + self._tpg = list(target.tpgs)[0] + else: + self._tpg = TPG(self._target, 1) + break + except Exception as e: + self.logger.warning(f"Error checking for existing target: {e}") + + if not target_exists: + self.logger.info(f"Creating new target: {self._iqn}") + fabric_modules = {m.name: m for m in list(self._rtsroot.fabric_modules)} # type: ignore[attr-defined] + iscsi_fabric = fabric_modules.get("iscsi") + if not iscsi_fabric: + raise ISCSIError("Could not find iSCSI fabric module") + self._target = Target(iscsi_fabric, self._iqn) + self._tpg = TPG(self._target, 1) + + self._tpg.enable = True # type: ignore[attr-defined] + + def _setup_network_portal(self): + """Setup the network portal for the target""" + portal_exists = False + try: + portals = list(self._tpg.network_portals) # type: ignore[attr-defined] + for portal in portals: + if portal.ip_address == self.host and portal.port == self.port: + portal_exists = True + break + except Exception as e: + self.logger.warning(f"Error checking for existing portal: {e}") + + if not portal_exists: + self.logger.info(f"Creating network portal on {self.host}:{self.port}") + NetworkPortal(self._tpg, self.host, self.port) + + def _configure_tpg_attributes(self): + """Configure TPG attributes""" + self._tpg.set_attribute("authentication", "0") # type: ignore[attr-defined] + self._tpg.set_attribute("generate_node_acls", "1") # type: ignore[attr-defined] + self._tpg.set_attribute("demo_mode_write_protect", "0") # type: ignore[attr-defined] + + @export + def start(self): + """Start the iSCSI target server + + Configures and starts the iSCSI target with the current configuration + + Raises: + ISCSIError: If the server fails to start + """ + try: + self._configure_target() + self.logger.info(f"iSCSI target server started at {self.host}:{self.port}") + except Exception as e: + raise ISCSIError(f"Failed to start iSCSI target server: {e}") from e + + @export + def stop(self): + """Stop the iSCSI target server + + Cleans up and stops the iSCSI target server + """ + try: + for name in list(self._luns.keys()): + # TODO: maybe leave? + self.remove_lun(name) + + if self._target: + self._target.delete() + self._target = None + + self.logger.info("iSCSI target server stopped") + except Exception as e: + self.logger.error(f"Error stopping iSCSI server: {e}") + raise ISCSIError(f"Failed to stop iSCSI target: {e}") from e + + @export + def get_host(self) -> str: + """Get the host address the server is bound to + + Returns: + str: The IP address or hostname + """ + return self.host + + @export + def get_port(self) -> int: + """Get the port number the server is listening on + + Returns: + int: The port number + """ + return self.port + + @export + def get_target_iqn(self) -> str: + """Get the IQN of the target + + Returns: + str: The IQN string + """ + return self._iqn + + def _validate_lun_inputs(self, name: str, size_mb: int) -> int: + """Validate LUN inputs and return validated size_mb""" + if name in self._luns: + raise ISCSIError(f"LUN with name {name} already exists") + try: + return int(size_mb) + except (TypeError, ValueError) as e: + raise ISCSIError("size_mb must be an integer value") from e + + def _get_full_path(self, file_path: str, is_block: bool) -> str: + """Get the full path for the LUN file or block device""" + if is_block: + if not os.path.isabs(file_path): + raise ISCSIError("For block devices, file_path must be an absolute path") + return file_path + else: + normalized_path = os.path.normpath(file_path) + + if normalized_path.startswith('..') or os.path.isabs(normalized_path): + raise ISCSIError(f"Invalid file path: {file_path}") + + full_path = os.path.join(self.root_dir, normalized_path) + resolved_path = os.path.abspath(full_path) + root_path = os.path.abspath(self.root_dir) + + if not resolved_path.startswith(root_path + os.sep) and resolved_path != root_path: + raise ISCSIError(f"Path traversal attempt detected: {file_path}") + os.makedirs(os.path.dirname(full_path), exist_ok=True) + return full_path + + def _create_file_storage_object(self, name: str, full_path: str, size_mb: int) -> tuple: + """Create file-backed storage object and return (storage_obj, final_size_mb)""" + if not os.path.exists(full_path): + if size_mb <= 0: + raise ISCSIError("size_mb must be > 0 for new file-backed LUNs") + size_bytes = size_mb * 1024 * 1024 + with open(full_path, "wb") as f: + f.truncate(size_bytes) + self.logger.info(f"Created new file {full_path} with size {size_mb}MB") + else: + current_size = os.path.getsize(full_path) + if size_mb <= 0: + size_bytes = current_size + size_mb = size_bytes // (1024 * 1024) + self.logger.info(f"Using existing file size: {size_mb}MB") + else: + size_bytes = size_mb * 1024 * 1024 + if current_size != size_bytes: + if current_size < size_bytes: + with open(full_path, "ab") as f: + f.truncate(size_bytes) + self.logger.info( + f"Extended file {full_path} from {current_size / (1024 * 1024):.1f}MB to {size_mb}MB" + ) + else: + self.logger.warning( + f"File {full_path} is larger ({current_size / (1024 * 1024):.1f}MB) " + f"than requested size ({size_mb}MB). " + "Using requested size for LUN but file won't be truncated." + ) + return FileIOStorageObject(name, full_path, size=size_bytes), size_mb + + @export + def add_lun(self, name: str, file_path: str, size_mb: int = 0, is_block: bool = False) -> str: + """ + Add a new LUN to the iSCSI target. + + For file-backed LUNs (is_block=False), the provided file_path is relative to the configured storage root. + For block devices (is_block=True), the file_path must be an absolute path and will be used as provided. + + Args: + name (str): Unique name for the LUN. + file_path (str): Path to the file or block device. + size_mb (int): Size in MB for new file-backed LUNs (ignored for block devices). + is_block (bool): If True, treat file_path as an absolute block device path. + + Returns: + str: The name of the created LUN. + + Raises: + ISCSIError: On error or if the file_path is invalid. + """ + size_mb = self._validate_lun_inputs(name, size_mb) + full_path = self._get_full_path(file_path, is_block) + + try: + if is_block: + if not os.path.exists(full_path): + raise ISCSIError(f"Block device {full_path} does not exist") + storage_obj = BlockStorageObject(name, full_path) + else: + storage_obj, size_mb = self._create_file_storage_object(name, full_path, size_mb) + + lun = LUN(self._tpg, 0, storage_obj) + self._storage_objects[name] = storage_obj + self._luns[name] = lun + self.logger.info(f"Added LUN {name} for path {full_path} with size {size_mb}MB") + return name + except Exception as e: + self.logger.error(f"Error adding LUN: {e}") + raise ISCSIError(f"Failed to add LUN: {e}") from e + + @export + def remove_lun(self, name: str): + """Remove a LUN from the iSCSI target + + Args: + name (str): Name of the LUN to remove + + Raises: + ISCSIError: If the LUN cannot be removed + """ + if not self._tpg: + raise ISCSIError("iSCSI target not started, call start() first") + + if name not in self._luns: + raise ISCSIError(f"LUN with name {name} does not exist") + + try: + self._luns[name].delete() + self._storage_objects[name].delete() + + del self._luns[name] + del self._storage_objects[name] + + self.logger.info(f"Removed LUN {name}") + except Exception as e: + self.logger.error(f"Error removing LUN: {e}") + raise ISCSIError(f"Failed to remove LUN: {e}") from e + + @export + def list_luns(self) -> List[Dict[str, Any]]: + """List all configured LUNs + + Returns: + List[Dict[str, Any]]: List of dictionaries with LUN information + """ + result = [] + for name, lun in self._luns.items(): + storage_obj = self._storage_objects[name] + lun_info = { + "name": name, + "path": storage_obj.udev_path, + "size": storage_obj.size, + "lun_id": lun.lun, + "is_block": isinstance(storage_obj, BlockStorageObject), + } + result.append(lun_info) + return result + + def close(self): + """Clean up resources when the driver is closed""" + try: + self.stop() + except Exception as e: + self.logger.error(f"Error during cleanup: {e}") + super().close() diff --git a/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/py.typed b/packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-iscsi/pyproject.toml b/packages/jumpstarter-driver-iscsi/pyproject.toml new file mode 100644 index 000000000..8259bcfe7 --- /dev/null +++ b/packages/jumpstarter-driver-iscsi/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "jumpstarter-driver-iscsi" +dynamic = ["version", "urls"] +description = "Exporter ISCSI service driver" +readme = "README.md" +authors = [{ name = "Benny Zlotnik", email = "bzlotnik@redhat.com" }] +requires-python = ">=3.11" +dependencies = [ + "anyio>=4.6.2.post1", + "jumpstarter", + "jumpstarter-driver-composite", + "jumpstarter-driver-opendal", + "rtslib-fb", +] + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../' } + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.pytest.ini_options] +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["src"] + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest-cov>=6.0.0", + "pytest>=8.3.3", + "pytest-asyncio>=0.24.0", +] diff --git a/pyproject.toml b/pyproject.toml index 073d5266d..b4a8bb1cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ jumpstarter-driver-tftp = { workspace = true } jumpstarter-driver-snmp = { workspace = true } jumpstarter-driver-shell = { workspace = true } jumpstarter-driver-uboot = { workspace = true } +jumpstarter-driver-iscsi = { workspace = true } jumpstarter-driver-ustreamer = { workspace = true } jumpstarter-driver-yepkit = { workspace = true } jumpstarter-imagehash = { workspace = true }