1+ import contextlib
12import hashlib
23import os
34from dataclasses import dataclass
5+ from tempfile import NamedTemporaryFile
46from typing import Any , Dict , List , Optional
7+ from urllib .parse import urlparse
58
9+ import click
10+ import requests
611from jumpstarter_driver_composite .client import CompositeClient
712from jumpstarter_driver_opendal .common import PathBuf
813from 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