Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit ae62043

Browse files
authored
Merge pull request #661 from bennyz/iscsi-cli
iscsi: add support for serving files via CLI
2 parents 17cf5d2 + d0678ec commit ae62043

4 files changed

Lines changed: 290 additions & 23 deletions

File tree

packages/jumpstarter-driver-iscsi/examples/exporter.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ export:
1313
iqn_prefix: "iqn.2024-06.dev.jumpstarter"
1414
target_name: "my-target"
1515
host: ""
16-
port: 3260
16+
port: 3260

packages/jumpstarter-driver-iscsi/jumpstarter_driver_iscsi/client.py

Lines changed: 168 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import contextlib
12
import hashlib
23
import os
34
from dataclasses import dataclass
5+
from tempfile import NamedTemporaryFile
46
from typing import Any, Dict, List, Optional
7+
from urllib.parse import urlparse
58

9+
import click
10+
import requests
611
from jumpstarter_driver_composite.client import CompositeClient
712
from jumpstarter_driver_opendal.common import PathBuf
813
from opendal import Operator
@@ -64,6 +69,62 @@ def get_target_iqn(self) -> str:
6469
"""
6570
return self.call("get_target_iqn")
6671

72+
def _normalized_name_from_file(self, path: str) -> str:
73+
base = os.path.basename(path)
74+
for ext in (".gz", ".xz", ".bz2"):
75+
if base.endswith(ext):
76+
base = base[: -len(ext)]
77+
break
78+
if base.endswith(".img"):
79+
base = base[: -len(".img")]
80+
return base or "image"
81+
82+
def _get_src_and_operator(
83+
self, file: str, headers: tuple[str, ...]
84+
) -> tuple[str, Optional[Operator], Optional[str]]:
85+
from jumpstarter_driver_opendal.client import operator_for_path
86+
87+
if file.startswith(("http://", "https://")):
88+
if headers:
89+
header_map: Dict[str, str] = {}
90+
for h in headers:
91+
if ":" not in h:
92+
raise click.ClickException(f"Invalid header format: {h!r}. Expected 'Key: Value'.")
93+
key, value = h.split(":", 1)
94+
key = key.strip()
95+
value = value.strip()
96+
if not key:
97+
raise click.ClickException(f"Invalid header key in: {h!r}")
98+
header_map[key] = value
99+
100+
parsed = urlparse(file)
101+
tf = NamedTemporaryFile(
102+
prefix="jumpstarter-iscsi-",
103+
suffix=os.path.basename(parsed.path),
104+
delete=False,
105+
)
106+
temp_path = tf.name
107+
try:
108+
with requests.get(file, stream=True, headers=header_map, timeout=(10, 60)) as resp:
109+
resp.raise_for_status()
110+
for chunk in resp.iter_content(chunk_size=65536):
111+
if chunk:
112+
tf.write(chunk)
113+
tf.close()
114+
return temp_path, None, temp_path
115+
except Exception:
116+
tf.close()
117+
with contextlib.suppress(Exception):
118+
os.unlink(temp_path)
119+
raise
120+
121+
_, src_operator, _ = operator_for_path(file)
122+
return file, src_operator, None
123+
124+
file = os.path.abspath(file)
125+
_, src_operator, _ = operator_for_path(file)
126+
return file, src_operator, None
127+
67128
def add_lun(self, name: str, file_path: str, size_mb: int = 0, is_block: bool = False) -> str:
68129
"""
69130
Add a new LUN to the iSCSI target
@@ -112,11 +173,12 @@ def _calculate_file_hash(self, file_path: str, operator: Optional[Operator] = No
112173
hash_obj.update(chunk)
113174
return hash_obj.hexdigest()
114175
else:
115-
from jumpstarter_driver_opendal.client import operator_for_path
116-
117-
path, op, _ = operator_for_path(file_path)
118176
hash_obj = hashlib.sha256()
119-
with op.open(str(path), "rb") as f:
177+
if isinstance(file_path, str) and file_path.startswith(("http://", "https://")):
178+
src_path = urlparse(file_path).path
179+
else:
180+
src_path = str(file_path)
181+
with operator.open(str(src_path), "rb") as f:
120182
while chunk := f.read(8192):
121183
hash_obj.update(chunk)
122184
return hash_obj.hexdigest()
@@ -125,6 +187,7 @@ def _files_are_identical(self, src: PathBuf, dst_path: str, operator: Optional[O
125187
"""Check if source and destination files are identical"""
126188
try:
127189
if not self.storage.exists(dst_path):
190+
self.logger.info(f"{dst_path} does not exist")
128191
return False
129192

130193
dst_stat = self.storage.stat(dst_path)
@@ -133,22 +196,58 @@ def _files_are_identical(self, src: PathBuf, dst_path: str, operator: Optional[O
133196
if operator is None:
134197
src_size = os.path.getsize(str(src))
135198
else:
136-
from jumpstarter_driver_opendal.client import operator_for_path
137-
138-
path, op, _ = operator_for_path(src)
139-
src_size = op.stat(str(path)).content_length
199+
if isinstance(src, str) and src.startswith(("http://", "https://")):
200+
src_path = urlparse(src).path
201+
else:
202+
src_path = str(src)
203+
src_size = operator.stat(str(src_path)).content_length
140204

141205
if src_size != dst_size:
206+
self.logger.info(f"Source size {src_size} != destination size {dst_size}")
142207
return False
143208

209+
self.logger.info("checking hashes")
144210
src_hash = self._calculate_file_hash(str(src), operator)
211+
self.logger.info(f"Source hash: {src_hash}")
145212
dst_hash = self.storage.hash(dst_path, "sha256")
213+
self.logger.info(f"Destination hash: {dst_hash}")
146214

147215
return src_hash == dst_hash
148216

149217
except Exception:
150218
return False
151219

220+
def _should_skip_upload(
221+
self, src_path: str, dst_path: str, operator: Optional[Operator], force_upload: bool, algo: Optional[str]
222+
) -> bool:
223+
if force_upload or algo is not None or not self.storage.exists(dst_path):
224+
return False
225+
226+
self.logger.info(f"Checking if {src_path} and {dst_path} are identical")
227+
if self._files_are_identical(src_path, dst_path, operator):
228+
self.logger.info(f"File {dst_path} already exists and is identical to source. Skipping upload...")
229+
return True
230+
231+
self.logger.info(f"File {dst_path} is not identical to source")
232+
return False
233+
234+
def _upload_file(
235+
self, src_path: str, dst_name: str, dst_path: str, operator: Optional[Operator], algo: Optional[str]
236+
):
237+
if algo is None:
238+
self.logger.info(f"Uploading {src_path} to {dst_path}...")
239+
self.storage.write_from_path(dst_path, src_path, operator)
240+
else:
241+
ext_to_algo = {".gz": "gz", ".xz": "xz", ".bz2": "bz2"}
242+
ext = next(k for k, v in ext_to_algo.items() if v == algo)
243+
compressed_path = f"{dst_name}.img{ext}"
244+
self.logger.info(f"Uploading {src_path} to {compressed_path}...")
245+
self.storage.write_from_path(compressed_path, src_path, operator)
246+
self.logger.info(f"Decompressing on exporter: {compressed_path} -> {dst_name}.img ...")
247+
self.call("decompress", compressed_path, f"{dst_name}.img", algo)
248+
with contextlib.suppress(Exception):
249+
self.storage.delete(compressed_path)
250+
152251
def upload_image(
153252
self,
154253
dst_name: str,
@@ -176,18 +275,70 @@ def upload_image(
176275
size_mb = int(size_mb)
177276
dst_path = f"{dst_name}.img"
178277

179-
if not force_upload and self._files_are_identical(src, dst_path, operator):
180-
print(f"File {dst_path} already exists and is identical to source. Skipping upload.")
181-
else:
182-
print(f"Uploading {src} to {dst_path}...")
183-
self.storage.write_from_path(dst_path, src, operator)
278+
src_path = str(src)
279+
if operator is None and not src_path.startswith(("http://", "https://")):
280+
src_path = os.path.abspath(src_path)
281+
282+
ext_to_algo = {".gz": "gz", ".xz": "xz", ".bz2": "bz2"}
283+
algo = next((v for k, v in ext_to_algo.items() if src_path.endswith(k)), None)
284+
285+
if not self._should_skip_upload(src_path, dst_path, operator, force_upload, algo):
286+
self._upload_file(src_path, dst_name, dst_path, operator, algo)
184287

185288
if size_mb <= 0:
186-
src_path = os.path.join(self.storage._storage.root_dir, dst_path)
187-
size_mb = os.path.getsize(src_path) // (1024 * 1024)
188-
if size_mb <= 0:
289+
try:
290+
dst_stat = self.storage.stat(dst_path)
291+
size_mb = max(1, int(dst_stat.content_length) // (1024 * 1024))
292+
except Exception:
189293
size_mb = 1
190294

191295
self.add_lun(dst_name, dst_path, size_mb)
192-
193296
return self.get_target_iqn()
297+
298+
def cli(self):
299+
base = super().cli()
300+
301+
@base.command()
302+
@click.argument("file", type=str)
303+
@click.option("--name", "name", "-n", type=str, help="LUN name (defaults to basename without extension)")
304+
@click.option("--size-mb", type=int, default=0, show_default=True, help="Size in MB if creating a new image")
305+
@click.option(
306+
"--force-upload",
307+
is_flag=True,
308+
default=False,
309+
help="Force uploading even if the file appears identical on the exporter",
310+
)
311+
@click.option(
312+
"--header",
313+
"headers",
314+
multiple=True,
315+
help="Custom HTTP header in 'Key: Value' format. Repeatable.",
316+
)
317+
def serve(file: str, name: Optional[str], size_mb: int, force_upload: bool, headers: tuple[str, ...]):
318+
"""Serve an image as an iSCSI LUN from a local path or HTTP(S) URL."""
319+
self.start()
320+
321+
try:
322+
self.call("clear_all_luns")
323+
except Exception:
324+
pass
325+
326+
if not name:
327+
candidate = urlparse(file).path if file.startswith(("http://", "https://")) else file
328+
name = self._normalized_name_from_file(candidate)
329+
330+
src_path, src_operator, temp_cleanup = self._get_src_and_operator(file, headers)
331+
try:
332+
iqn = self.upload_image(
333+
name, src_path, size_mb=size_mb, operator=src_operator, force_upload=force_upload
334+
)
335+
finally:
336+
if temp_cleanup is not None:
337+
with contextlib.suppress(Exception):
338+
os.remove(temp_cleanup)
339+
host = self.get_host()
340+
port = self.get_port()
341+
342+
click.echo(f"{host}:{port} {iqn}")
343+
344+
return base

0 commit comments

Comments
 (0)