Coverage for volexport/tgtd.py: 80%
324 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-28 12:48 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-09-28 12:48 +0000
1from urllib.parse import urlsplit
2from socket import AF_INET6, AF_INET
3import shlex
4import secrets
5import ifaddr
6import tempfile
7from pathlib import Path
8from logging import getLogger
9from typing import Sequence, Callable, TypedDict
10from .config import config
11from .config2 import config2
12from .util import runcmd
14_log = getLogger(__name__)
17class Tgtd:
18 """Class to manage tgtadm operations for stgt"""
20 def __init__(self):
21 self.lld = "iscsi"
23 def parse(self, lines: Sequence[str]):
24 """Parse the output of tgtadm"""
26 class genline(TypedDict):
27 indent: int
28 key: str
29 value: str | None
31 def linegen(lines: Sequence[str]):
32 for line in lines:
33 indent = len(line) - len(line.lstrip())
34 if " (" in line:
35 kv = line[indent:].split("(", 1)
36 if len(kv) == 2: 36 ↛ 41line 36 didn't jump to line 41 because the condition on line 36 was always true
37 v = kv[1].strip(" )")
38 if v == "": 38 ↛ 39line 38 didn't jump to line 39 because the condition on line 38 was never true
39 v = None
40 yield genline(indent=indent, key=kv[0].strip(), value=v)
41 elif len(kv) == 1:
42 yield genline(indent=indent, key=kv[0].strip(), value=None)
43 elif ":" in line or "=" in line:
44 kv = line[indent:].split(":", 1)
45 if len(kv) == 1:
46 kv = line[indent:].split("=", 1)
47 if len(kv) == 2: 47 ↛ 32line 47 didn't jump to line 32 because the condition on line 47 was always true
48 k = kv[0].strip()
49 v = kv[1].strip()
50 if v == "":
51 v = None
52 if k in ("LUN", "I_T nexus", "Connection", "Session"): # special case
53 k = f"{k} {v}"
54 yield genline(indent=indent, key=k, value=v)
55 elif len(line[indent:]) != 0:
56 yield genline(indent=indent, key=line[indent:].strip(), value=None)
58 res = {}
59 levels: list[str] = []
60 for node in linegen(lines):
61 assert node["indent"] % 4 == 0
62 level = int(node["indent"] / 4)
63 _log.debug("node %s, level=%s", node, level)
64 # select target
65 target = res
66 for k in levels[:level]:
67 if target.get(k) is None:
68 target[k] = {}
69 if isinstance(target[k], dict):
70 target = target[k]
71 else:
72 target[k] = dict(name=target[k])
73 target = target[k]
74 levels = levels[:level]
75 levels.append(node["key"])
76 target[node["key"]] = node.get("value")
77 _log.debug("levels: %s, res=%s", levels, res)
78 return res
80 def tgtadm(self, **kwargs):
81 """Run tgtadm command with given parameters"""
82 cmd = shlex.split(config.TGTADM_BIN)
83 for k, v in kwargs.items():
84 if len(k) == 1: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 cmd.append(f"-{k}")
86 else:
87 cmd.append(f"--{k.replace('_', '-')}")
88 if v is not None: 88 ↛ 83line 88 didn't jump to line 83 because the condition on line 88 was always true
89 if isinstance(v, dict):
90 cmd.append(",".join([f"{kk}={vv}" for kk, vv in v.items()]))
91 else:
92 cmd.append(str(v))
93 return runcmd(cmd, True)
95 def target_create(self, tid: int, name: str):
96 """Create a new target with the given TID and name"""
97 return self.tgtadm(lld=self.lld, mode="target", op="new", tid=tid, targetname=name)
99 def target_delete(self, tid: int, force: bool = False):
100 """Delete a target by TID"""
101 if force: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 return self.tgtadm(lld=self.lld, mode="target", op="delete", force=None, tid=tid)
103 return self.tgtadm(lld=self.lld, mode="target", op="delete", tid=tid)
105 def target_list(self):
106 """List all targets"""
107 return self.parse(self.tgtadm(lld=self.lld, mode="target", op="show").stdout.splitlines())
109 def target_show(self, tid: int):
110 """Show details of a target by TID"""
111 return self.parse(self.tgtadm(lld=self.lld, mode="target", op="show", tid=tid).stdout.splitlines())
113 def target_update(self, tid: int, param, value):
114 """Update a target parameter by TID"""
115 return self.tgtadm(lld=self.lld, mode="target", op="update", tid=tid, name=param, value=value)
117 def target_bind_address(self, tid: int, addr):
118 """Bind a target to an initiator address"""
119 return self.tgtadm(lld=self.lld, mode="target", op="bind", tid=tid, initiator_address=addr)
121 def target_bind_name(self, tid: int, name):
122 """Bind a target to an initiator name"""
123 return self.tgtadm(lld=self.lld, mode="target", op="bind", tid=tid, initiator_name=name)
125 def target_unbind_address(self, tid: int, addr):
126 """Unbind a target from an initiator address"""
127 return self.tgtadm(lld=self.lld, mode="target", op="unbind", tid=tid, initiator_address=addr)
129 def target_unbind_name(self, tid: int, name):
130 """Unbind a target from an initiator name"""
131 return self.tgtadm(lld=self.lld, mode="target", op="unbind", tid=tid, initiator_name=name)
133 def lun_create(self, tid: int, lun: int, path: str, **kwargs):
134 """Create a new logical unit (LUN) for a target"""
135 return self.tgtadm(lld=self.lld, mode="logicalunit", op="new", tid=tid, lun=lun, backing_store=path, **kwargs)
137 def lun_update(self, tid: int, lun: int, **kwargs):
138 """Update an existing logical unit (LUN) for a target"""
139 return self.tgtadm(lld=self.lld, mode="logicalunit", op="update", tid=tid, lun=lun, params=kwargs)
141 def lun_delete(self, tid: int, lun: int):
142 """Delete a logical unit (LUN) from a target"""
143 return self.tgtadm(lld=self.lld, mode="logicalunit", op="delete", tid=tid, lun=lun)
145 def account_create(self, user: str, password: str, outgoing: bool = False):
146 """Create a new account for a target"""
147 if outgoing: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true
148 return self.tgtadm(lld=self.lld, mode="account", op="new", user=user, password=password, outgoing=None)
149 return self.tgtadm(lld=self.lld, mode="account", op="new", user=user, password=password)
151 def account_list(self):
152 """List all accounts for the target"""
153 return self.parse(self.tgtadm(lld=self.lld, mode="account", op="show").stdout.splitlines())
155 def account_delete(self, user: str, outgoing: bool = False):
156 """Delete an account from the target"""
157 if outgoing: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true
158 return self.tgtadm(lld=self.lld, mode="account", op="delete", user=user, outgoing=None)
159 return self.tgtadm(lld=self.lld, mode="account", op="delete", user=user)
161 def account_bind(self, tid: int, user: str):
162 """Bind an account to a target"""
163 return self.tgtadm(lld=self.lld, mode="account", op="bind", tid=tid, user=user)
165 def account_unbind(self, tid: int, user: str):
166 """Unbind an account from a target"""
167 return self.tgtadm(lld=self.lld, mode="account", op="unbind", tid=tid, user=user)
169 def lld_start(self):
170 """Start the LLD"""
171 return self.tgtadm(lld=self.lld, mode="lld", op="start")
173 def lld_stop(self):
174 """Stop the LLD"""
175 return self.tgtadm(lld=self.lld, mode="lld", op="stop")
177 def sys_show(self):
178 """Get system information"""
179 return self.parse(self.tgtadm(mode="sys", op="show").stdout.splitlines())
181 def sys_set(self, name, value):
182 """Set a system parameter"""
183 return self.tgtadm(mode="sys", op="update", name=name, value=value)
185 def sys_ready(self):
186 """Set the system state to ready"""
187 return self.tgtadm(mode="sys", op="update", name="State", value="ready")
189 def sys_offline(self):
190 """Set the system state to offline"""
191 return self.tgtadm(mode="sys", op="update", name="State", value="offline")
193 def portal_list(self):
194 """List all portals"""
195 return [
196 x.split(":", 1)[-1].strip()
197 for x in self.tgtadm(lld=self.lld, mode="portal", op="show").stdout.splitlines()
198 if x.startswith("Portal:")
199 ]
201 def portal_add(self, hostport):
202 """Add a new portal"""
203 return self.tgtadm(lld=self.lld, mode="portal", op="new", param=dict(portal=hostport))
205 def portal_delete(self, hostport):
206 """Delete a portal"""
207 return self.tgtadm(lld=self.lld, mode="portal", op="delete", param=dict(portal=hostport))
209 def list_session(self, tid: int):
210 """List all sessions for a target"""
211 return self.parse(self.tgtadm(lld=self.lld, mode="conn", op="show", tid=tid).stdout.splitlines())
213 def disconnect_session(self, tid: int, sid: int, cid: int):
214 """Disconnect a session by TID, SID, and CID"""
215 return self.tgtadm(lld=self.lld, mode="conn", op="delete", tid=tid, sid=sid, cid=cid)
217 def myaddress(self):
218 """Get the addresses of the target"""
219 portal_addrs = [x.removesuffix(",1") for x in self.portal_list()]
220 res = []
221 ifaddrs = {AF_INET: [], AF_INET6: []}
222 for adapter in ifaddr.get_adapters():
223 _log.debug("check %s / %s", adapter, config2.NICS)
224 if adapter.name in config2.NICS:
225 _log.debug("adapter %s", adapter.name)
226 for ip in adapter.ips:
227 if isinstance(ip.ip, tuple):
228 if ip.ip[2] != 0: 228 ↛ 231line 228 didn't jump to line 231 because the condition on line 228 was always true
229 # scope id (is link local address)
230 continue
231 addr = ip.ip[0]
232 else:
233 addr = ip.ip
234 if ip.is_IPv4:
235 ifaddrs[AF_INET].append(addr)
236 elif ip.is_IPv6: 236 ↛ 226line 236 didn't jump to line 226 because the condition on line 236 was always true
237 ifaddrs[AF_INET6].append(addr)
238 _log.debug("ifaddrs: %s", ifaddrs)
239 for a in portal_addrs:
240 u = urlsplit("//" + a)
241 port = u.port or 3260
242 _log.warning("url: %s (hostname=%s)", u, u.hostname)
243 if u.hostname == "0.0.0.0":
244 _log.warning("v4 address: %s", ifaddrs[AF_INET])
245 # all v4 addr
246 res.extend([f"{x}:{port}" for x in ifaddrs[AF_INET]])
247 elif u.hostname == "::": 247 ↛ 239line 247 didn't jump to line 239 because the condition on line 247 was always true
248 _log.warning("v6 address: %s", ifaddrs[AF_INET6])
249 # all v6 addr
250 res.extend([f"[{x}]:{port}" for x in ifaddrs[AF_INET6] if "%" not in x])
251 return res
253 # tgt-admin operations
254 def dump(self):
255 """Dump the current configuration"""
256 res = runcmd(["tgt-admin", "--dump"], root=True)
257 return res.stdout
259 def restore(self, data: str):
260 """Restore configuration from the given data"""
261 with tempfile.NamedTemporaryFile("r+") as tf:
262 tf.write(data)
263 tf.flush()
264 res = runcmd(["tgt-admin", "-c", tf.name, "-e"], root=True)
265 return res.stdout
267 def _target2export(self, tgtid: str, tgtinfo: dict) -> dict:
268 tgtid = tgtid.removeprefix("Target ")
269 name = tgtinfo["name"]
270 itn = tgtinfo.get("I_T nexus information", {})
271 connected_from = []
272 if itn is not None: 272 ↛ 283line 272 didn't jump to line 283 because the condition on line 272 was always true
273 addrs = []
274 for itnv in itn.values():
275 addrs.extend([v.get("IP Address") for k, v in itnv.items() if k.startswith("Connection")])
276 connected_from = [
277 {
278 "address": addrs,
279 "initiator": x.get("Initiator").split(" ")[0],
280 }
281 for x in itn.values()
282 ]
283 luns = tgtinfo.get("LUN information", {})
284 volumes = []
285 for lun in luns.values():
286 if lun["Type"] == "controller":
287 continue
288 volumes.append(lun["Backing store path"])
289 accounts = list((tgtinfo.get("Account information") or {}).keys())
290 acls = list((tgtinfo.get("ACL information") or {}).keys())
291 return dict(
292 protocol=self.lld,
293 tid=tgtid,
294 targetname=name,
295 connected=connected_from,
296 volumes=volumes,
297 users=accounts,
298 acl=acls,
299 )
301 def _find_target(self, fn: Callable):
302 return next(((tgtid, tgt) for tgtid, tgt in self.target_list().items() if fn(tgtid, tgt)), (None, None))
304 def _find_export(self, fn: Callable):
305 return next((tgt for tgt in self.export_list() if fn(tgt)), None)
307 # compound operation
308 def export_list(self):
309 """List all exports"""
310 res = []
311 for tgtid, tgtinfo in self.target_list().items():
312 if tgtinfo is None: 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true
313 continue
314 res.append(self._target2export(tgtid, tgtinfo))
315 return res
317 def export_read(self, tid):
318 """Read exports"""
319 tgtid, tgtinfo = self._find_target(lambda t, tinfo: int(t.removeprefix("Target ")) == tid)
320 if tgtid is None or tgtinfo is None:
321 raise FileNotFoundError(f"target {tid} not found")
322 return self._target2export(tgtid, tgtinfo)
324 def export_volume(
325 self, filename: str, acl: list[str], readonly: bool = False, user: str | None = None, passwd: str | None = None
326 ):
327 """Export a volume by its filename with specified ACL and read-only option"""
328 if not Path(filename).exists(): 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true
329 _log.error("does not exists: %s", filename)
330 raise FileNotFoundError(f"volume does not exists: {filename}")
331 iqname = secrets.token_hex(10)
332 tgts = [x.removeprefix("Target ") for x in self.target_list().keys() if x.startswith("Target ")]
333 _log.debug("existing target: %s", tgts)
334 tgts.append("0")
335 max_tgt = max([int(x) for x in tgts])
336 tid = max_tgt + 1
337 lun = 1
338 name = f"{config.IQN_BASE}:{iqname}"
339 if not user:
340 user = secrets.token_hex(10)
341 if not passwd:
342 passwd = secrets.token_hex(20)
343 self.target_create(tid=tid, name=name)
344 opts = {}
345 if config.TGT_BSOPTS: 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true
346 opts["bsopts"] = config.TGT_BSOPTS
347 if config.TGT_BSOFLAGS: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true
348 opts["bsoflags"] = config.TGT_BSOFLAGS
349 if readonly:
350 # not supported?
351 # opts["params"] = dict(readonly=1)
352 pass
353 self.lun_create(tid=tid, lun=lun, path=filename, bstype=config.TGT_BSTYPE, **opts)
354 self.lun_update(tid=tid, lun=lun, vendor_id="VOLEXP", product_id=Path(filename).name)
355 self.account_create(user=user, password=passwd)
356 self.account_bind(tid=tid, user=user)
357 for addr in acl:
358 self.target_bind_address(tid=tid, addr=addr)
359 addrs = self.myaddress()
360 return dict(
361 protocol=self.lld,
362 addresses=addrs, # list of host:port
363 targetname=name,
364 tid=tid,
365 user=user,
366 passwd=passwd,
367 lun=lun,
368 acl=acl,
369 )
371 def _refresh_lun(self, tid: int, lun: int, luninfo: dict):
372 pathname = luninfo["Backing store path"]
373 readonly = luninfo["Readonly"] in ("Yes",)
374 opts = {}
375 if config.TGT_BSOPTS: 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true
376 opts["bsopts"] = config.TGT_BSOPTS
377 if config.TGT_BSOFLAGS: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true
378 opts["bsoflags"] = config.TGT_BSOFLAGS
379 if readonly: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true
380 opts["params"] = dict(readonly=1)
381 self.lun_delete(tid=tid, lun=lun)
382 self.lun_create(tid=tid, lun=lun, path=pathname, bstype=config.TGT_BSTYPE, **opts)
384 def refresh_volume(self, tid: int, lun: int):
385 tgtid, tgtinfo = self._find_target(lambda t, info: int(t.removeprefix("Target ")) == tid)
386 if tgtid is None or tgtinfo is None:
387 raise FileNotFoundError(f"target {tid} not found")
388 luns = tgtinfo.get("LUN information", {})
389 for lunid, luninfo in luns.items():
390 lunid = lunid.removeprefix("LUN ")
391 if int(lunid) != lun:
392 continue
393 _log.info("found lun: tid=%s, lun=%s, info=%s", tgtid, lunid, luninfo)
394 self._refresh_lun(tid, lun, luninfo)
395 break
396 else:
397 raise FileNotFoundError(f"lun {lun} not found")
399 def refresh_volume_bypath(self, pathname: str):
400 found = False
401 for tgtid, tgtinfo in self.target_list().items():
402 if tgtinfo is None: 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true
403 continue
404 tid = int(tgtid.removeprefix("Target "))
405 luns = tgtinfo.get("LUN information", {})
406 for lunid, luninfo in luns.items():
407 lun = int(lunid.removeprefix("LUN "))
408 bs_pathname = luninfo["Backing store path"]
409 if bs_pathname != pathname:
410 continue
411 _log.info("found lun: tid=%s, lun=%s, info=%s", tid, lun, luninfo)
412 self._refresh_lun(tid, lun, luninfo)
413 found = True
414 if not found: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true
415 raise FileNotFoundError(f"volume {pathname} is not exported")
417 def get_export_bypath(self, filename: str):
418 """Get export details by volume path"""
419 return self._find_export(lambda tgt: filename in tgt.get("volumes"))
421 def get_export_byname(self, targetname: str):
422 """Get export details by target name"""
423 return self._find_export(lambda tgt: targetname == tgt.get("targetname"))
425 def unexport_volume(self, targetname: str, force: bool = False):
426 """Unexport a volume by target name"""
427 tgtid, tgtinfo = self._find_target(lambda id, tgt: tgt.get("name") == targetname)
428 if tgtid is None or tgtinfo is None:
429 raise FileNotFoundError(f"target not found: {targetname}")
430 tgtid = int(tgtid.split()[-1])
431 itn = tgtinfo.get("I_T nexus information")
432 if itn is not None:
433 _log.warning("client connected: %s", itn)
434 if not force: 434 ↛ 437line 434 didn't jump to line 437 because the condition on line 434 was always true
435 addrs = [x.get("Connection", {}).get("IP Address") for x in itn.values()]
436 raise FileExistsError(f"client connected: {addrs}")
437 try:
438 accounts = tgtinfo.get("Account information", {})
439 if accounts is not None: 439 ↛ 443line 439 didn't jump to line 443 because the condition on line 439 was always true
440 for acct in accounts.keys():
441 self.account_unbind(tid=tgtid, user=acct)
442 self.account_delete(user=acct)
443 acls = tgtinfo.get("ACL information", {})
444 if acls is not None: 444 ↛ 448line 444 didn't jump to line 448 because the condition on line 444 was always true
445 for acl in acls.keys():
446 if acl: 446 ↛ 445line 446 didn't jump to line 445 because the condition on line 446 was always true
447 self.target_unbind_address(tid=tgtid, addr=acl)
448 luns = tgtinfo.get("LUN information", {})
449 if luns is not None: 449 ↛ 460line 449 didn't jump to line 460 because the condition on line 449 was always true
450 for lun in sorted(
451 tgtinfo.get("LUN information", {}).values(), key=lambda f: int(f["name"]), reverse=True
452 ):
453 if lun.get("Type") != "controller":
454 lunid = int(lun["name"])
455 self.lun_delete(tid=tgtid, lun=lunid)
456 except Exception as e:
457 if not force:
458 raise
459 _log.info("ignore error %s: force delete", e)
460 self.target_delete(tid=tgtid, force=force)