Coverage for volexport/tgtd.py: 81%
256 statements
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-20 14:19 +0000
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-20 14:19 +0000
1from urllib.parse import urlsplit
2from socket import AF_INET6, AF_INET
3import shlex
4import secrets
5import ifaddr
6from pathlib import Path
7from logging import getLogger
8from typing import Sequence
9from .config import config
10from .util import runcmd
12_log = getLogger(__name__)
15class Tgtd:
16 def __init__(self):
17 self.lld = "iscsi"
19 def parse(self, lines: Sequence[str]):
20 def linegen(lines: Sequence[str]):
21 for line in lines:
22 indent = len(line) - len(line.lstrip())
23 if " (" in line:
24 kv = line[indent:].split("(", 1)
25 if len(kv) == 2: 25 ↛ 30line 25 didn't jump to line 30 because the condition on line 25 was always true
26 v = kv[1].strip(" )")
27 if v == "": 27 ↛ 28line 27 didn't jump to line 28 because the condition on line 27 was never true
28 v = None
29 yield {"indent": indent, "key": kv[0].strip(), "value": v}
30 elif len(kv) == 1:
31 yield {"indent": indent, "key": kv[0].strip()}
32 elif ":" in line or "=" in line:
33 kv = line[indent:].split(":", 1)
34 if len(kv) == 1:
35 kv = line[indent:].split("=", 1)
36 if len(kv) == 2: 36 ↛ 21line 36 didn't jump to line 21 because the condition on line 36 was always true
37 k = kv[0].strip()
38 v = kv[1].strip()
39 if v == "":
40 v = None
41 if k in ("LUN", "I_T nexus", "Connection", "Session"): # special case
42 k = f"{k} {v}"
43 yield {"indent": indent, "key": k, "value": v}
44 elif len(line[indent:]) != 0:
45 yield {"indent": indent, "key": line[indent:].strip()}
47 res = {}
48 levels: list[str] = []
49 for node in linegen(lines):
50 assert node["indent"] % 4 == 0
51 level = int(node["indent"] / 4)
52 _log.debug("node %s, level=%s", node, level)
53 # select target
54 target = res
55 for k in levels[:level]:
56 if target.get(k) is None:
57 target[k] = {}
58 if isinstance(target[k], dict):
59 target = target[k]
60 else:
61 target[k] = {"name": target[k]}
62 target = target[k]
63 levels = levels[:level]
64 levels.append(node["key"])
65 target[node["key"]] = node.get("value")
66 _log.debug("levels: %s, res=%s", levels, res)
67 return res
69 def tgtadm(self, **kwargs):
70 cmd = shlex.split(config.TGTADM_BIN)
71 for k, v in kwargs.items():
72 if len(k) == 1: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
73 cmd.append(f"-{k}")
74 else:
75 cmd.append(f"--{k.replace('_', '-')}")
76 if v is not None: 76 ↛ 71line 76 didn't jump to line 71 because the condition on line 76 was always true
77 if isinstance(v, dict):
78 cmd.append(",".join([f"{kk}={vv}" for kk, vv in v.items()]))
79 else:
80 cmd.append(str(v))
81 return runcmd(cmd, True)
83 def target_create(self, tid: int, name: str):
84 return self.tgtadm(lld=self.lld, mode="target", op="new", tid=tid, targetname=name)
86 def target_delete(self, tid: int, force: bool = False):
87 if force: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true
88 return self.tgtadm(lld=self.lld, mode="target", op="delete", force=None, tid=tid)
89 return self.tgtadm(lld=self.lld, mode="target", op="delete", tid=tid)
91 def target_list(self):
92 return self.parse(self.tgtadm(lld=self.lld, mode="target", op="show").stdout.splitlines())
94 def target_show(self, tid: int):
95 return self.parse(self.tgtadm(lld=self.lld, mode="target", op="show", tid=tid).stdout.splitlines())
97 def target_update(self, tid: int, param, value):
98 return self.tgtadm(lld=self.lld, mode="target", op="update", tid=tid, name=param, value=value)
100 def target_bind_address(self, tid: int, addr):
101 return self.tgtadm(lld=self.lld, mode="target", op="bind", tid=tid, initiator_address=addr)
103 def target_bind_name(self, tid: int, name):
104 return self.tgtadm(lld=self.lld, mode="target", op="bind", tid=tid, initiator_name=name)
106 def target_unbind_address(self, tid: int, addr):
107 return self.tgtadm(lld=self.lld, mode="target", op="unbind", tid=tid, initiator_address=addr)
109 def target_unbind_name(self, tid: int, name):
110 return self.tgtadm(lld=self.lld, mode="target", op="unbind", tid=tid, initiator_name=name)
112 def lun_create(self, tid: int, lun: int, path: str, **kwargs):
113 return self.tgtadm(lld=self.lld, mode="logicalunit", op="new", tid=tid, lun=lun, backing_store=path, **kwargs)
115 def lun_update(self, tid: int, lun: int, **kwargs):
116 return self.tgtadm(lld=self.lld, mode="logicalunit", op="update", tid=tid, lun=lun, params=kwargs)
118 def lun_delete(self, tid: int, lun: int):
119 return self.tgtadm(lld=self.lld, mode="logicalunit", op="delete", tid=tid, lun=lun)
121 def account_create(self, user: str, password: str, outgoing: bool = False):
122 if outgoing: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true
123 return self.tgtadm(lld=self.lld, mode="account", op="new", user=user, password=password, outgoing=None)
124 return self.tgtadm(lld=self.lld, mode="account", op="new", user=user, password=password)
126 def account_list(self):
127 return self.parse(self.tgtadm(lld=self.lld, mode="account", op="show").stdout.splitlines())
129 def account_delete(self, user: str, outgoing: bool = False):
130 if outgoing: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true
131 return self.tgtadm(lld=self.lld, mode="account", op="delete", user=user, outgoing=None)
132 return self.tgtadm(lld=self.lld, mode="account", op="delete", user=user)
134 def account_bind(self, tid: int, user: str):
135 return self.tgtadm(lld=self.lld, mode="account", op="bind", tid=tid, user=user)
137 def account_unbind(self, tid: int, user: str):
138 return self.tgtadm(lld=self.lld, mode="account", op="unbind", tid=tid, user=user)
140 def lld_start(self):
141 return self.tgtadm(lld=self.lld, mode="lld", op="start")
143 def lld_stop(self):
144 return self.tgtadm(lld=self.lld, mode="lld", op="stop")
146 def sys_show(self):
147 return self.parse(self.tgtadm(mode="sys", op="show").stdout.splitlines())
149 def sys_set(self, name, value):
150 return self.tgtadm(mode="sys", op="update", name=name, value=value)
152 def sys_ready(self):
153 return self.tgtadm(mode="sys", op="update", name="State", value="ready")
155 def sys_offline(self):
156 return self.tgtadm(mode="sys", op="update", name="State", value="offline")
158 def portal_list(self):
159 return [
160 x.split(":", 1)[-1].strip()
161 for x in self.tgtadm(lld=self.lld, mode="portal", op="show").stdout.splitlines()
162 if x.startswith("Portal:")
163 ]
165 def portal_add(self, hostport):
166 return self.tgtadm(lld=self.lld, mode="portal", op="new", param=dict(portal=hostport))
168 def portal_delete(self, hostport):
169 return self.tgtadm(lld=self.lld, mode="portal", op="delete", param=dict(portal=hostport))
171 def list_session(self, tid: int):
172 return self.parse(self.tgtadm(lld=self.lld, mode="conn", op="show", tid=tid).stdout.splitlines())
174 def disconnect_session(self, tid: int, sid: int, cid: int):
175 return self.tgtadm(lld=self.lld, mode="conn", op="delete", tid=tid, sid=sid, cid=cid)
177 def myaddress(self):
178 portal_addrs = [x.removesuffix(",1") for x in self.portal_list()]
179 res = []
180 ifaddrs = {AF_INET: [], AF_INET6: []}
181 for adapter in ifaddr.get_adapters():
182 if adapter.name in config.NICS:
183 for ip in adapter.ips:
184 if isinstance(ip.ip, tuple):
185 if ip.ip[2] != 0: 185 ↛ 188line 185 didn't jump to line 188 because the condition on line 185 was always true
186 # scope id (is link local address)
187 continue
188 addr = ip.ip[0]
189 else:
190 addr = ip.ip
191 if ip.is_IPv4:
192 ifaddrs[AF_INET].append(addr)
193 elif ip.is_IPv6: 193 ↛ 183line 193 didn't jump to line 183 because the condition on line 193 was always true
194 ifaddrs[AF_INET6].append(addr)
195 _log.debug("ifaddrs: %s", ifaddrs)
196 for a in portal_addrs:
197 u = urlsplit("//" + a)
198 port = u.port or 3260
199 _log.warning("url: %s (hostname=%s)", u, u.hostname)
200 if u.hostname == "0.0.0.0":
201 _log.warning("v4 address: %s", ifaddrs[AF_INET])
202 # all v4 addr
203 res.extend([f"{x}:{port}" for x in ifaddrs[AF_INET]])
204 elif u.hostname == "::": 204 ↛ 196line 204 didn't jump to line 196 because the condition on line 204 was always true
205 _log.warning("v6 address: %s", ifaddrs[AF_INET6])
206 # all v6 addr
207 res.extend([f"[{x}]:{port}" for x in ifaddrs[AF_INET6] if "%" not in x])
208 return res
210 # compound operation
211 def export_list(self):
212 res = []
213 for tgtid, tgtinfo in self.target_list().items():
214 if tgtinfo is None: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 continue
216 tgtid = tgtid.removeprefix("Target ")
217 name = tgtinfo["name"]
218 itn = tgtinfo.get("I_T nexus information", {})
219 connected_from = []
220 if itn is not None: 220 ↛ 231line 220 didn't jump to line 231 because the condition on line 220 was always true
221 addrs = []
222 for itnv in itn.values():
223 addrs.extend([v.get("IP Address") for k, v in itnv.items() if k.startswith("Connection")])
224 connected_from = [
225 {
226 "address": addrs,
227 "initiator": x.get("Initiator").split(" ")[0],
228 }
229 for x in itn.values()
230 ]
231 luns = tgtinfo.get("LUN information", {})
232 volumes = []
233 for lun in luns.values():
234 if lun["Type"] == "controller":
235 continue
236 volumes.append(lun["Backing store path"])
237 accounts = list(tgtinfo.get("Account information", {}).keys())
238 acls = list(tgtinfo.get("ACL information", {}).keys())
239 res.append(
240 dict(
241 protocol=self.lld,
242 tid=tgtid,
243 targetname=name,
244 connected=connected_from,
245 volumes=volumes,
246 users=accounts,
247 acl=acls,
248 )
249 )
250 return res
252 def export_volume(self, filename: str, acl: list[str], readonly: bool = False):
253 assert Path(filename).exists()
254 iqname = secrets.token_hex(10)
255 tgts = [x.removeprefix("Target ") for x in self.target_list().keys() if x.startswith("Target ")]
256 _log.debug("existing target: %s", tgts)
257 tgts.append("0")
258 max_tgt = max([int(x) for x in tgts])
259 tid = max_tgt + 1
260 lun = 1
261 name = f"{config.IQN_BASE}:{iqname}"
262 user = secrets.token_hex(10)
263 passwd = secrets.token_hex(20)
264 self.target_create(tid=tid, name=name)
265 opts = {}
266 if config.TGT_BSOPTS: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true
267 opts["bsopts"] = config.TGT_BSOPTS
268 if config.TGT_BSOFLAGS: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true
269 opts["bsoflags"] = config.TGT_BSOFLAGS
270 if readonly:
271 opts["params"] = dict(readonly=1)
272 self.lun_create(tid=tid, lun=lun, path=filename, bstype=config.TGT_BSTYPE, **opts)
273 self.account_create(user=user, password=passwd)
274 self.account_bind(tid=tid, user=user)
275 for addr in acl:
276 self.target_bind_address(tid=tid, addr=addr)
277 addrs = self.myaddress()
278 return dict(
279 protocol=self.lld,
280 addresses=addrs, # list of host:port
281 targetname=name,
282 tid=tid,
283 user=user,
284 passwd=passwd,
285 lun=lun,
286 acl=acl,
287 )
289 def get_export_bypath(self, filename: str):
290 res = self.export_list()
291 for tgt in res:
292 if filename in tgt.get("volumes"):
293 return tgt
295 def get_export_byname(self, targetname: str):
296 res = self.export_list()
297 for tgt in res:
298 if targetname == tgt.get("targetname"):
299 return tgt
301 def unexport_volume(self, targetname: str, force: bool = False):
302 info = self.target_list()
303 for tgtidstr, data in info.items():
304 if data.get("name") != targetname:
305 continue
306 tgtid = int(tgtidstr.split()[-1])
307 itn = data.get("I_T nexus information")
308 if itn is not None:
309 _log.warning("client connected: %s", itn)
310 if not force: 310 ↛ 313line 310 didn't jump to line 313 because the condition on line 310 was always true
311 addrs = [x.get("Connection", {}).get("IP Address") for x in itn.values()]
312 raise FileExistsError(f"client connected: {addrs}")
313 try:
314 accounts = data.get("Account information", {})
315 if accounts is not None: 315 ↛ 319line 315 didn't jump to line 319 because the condition on line 315 was always true
316 for acct in accounts.keys():
317 self.account_unbind(tid=tgtid, user=acct)
318 self.account_delete(user=acct)
319 acls = data.get("ACL information", {})
320 if acls is not None: 320 ↛ 323line 320 didn't jump to line 323 because the condition on line 320 was always true
321 for acl in acls.keys():
322 self.target_unbind_address(tid=tgtid, addr=acl)
323 luns = data.get("LUN information", {})
324 if luns is not None: 324 ↛ 334line 324 didn't jump to line 334 because the condition on line 324 was always true
325 for lun in sorted(
326 data.get("LUN information", {}).values(), key=lambda f: int(f["name"]), reverse=True
327 ):
328 if lun.get("Type") != "controller":
329 lunid = int(lun["name"])
330 self.lun_delete(tid=tgtid, lun=lunid)
331 except Exception:
332 if not force:
333 raise
334 self.target_delete(tid=tgtid, force=force)
335 break
336 else:
337 raise FileNotFoundError(f"target not found: {targetname}")