Coverage for volexport/tgtd.py: 80%

324 statements  

« 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 

13 

14_log = getLogger(__name__) 

15 

16 

17class Tgtd: 

18 """Class to manage tgtadm operations for stgt""" 

19 

20 def __init__(self): 

21 self.lld = "iscsi" 

22 

23 def parse(self, lines: Sequence[str]): 

24 """Parse the output of tgtadm""" 

25 

26 class genline(TypedDict): 

27 indent: int 

28 key: str 

29 value: str | None 

30 

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) 

57 

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 

79 

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) 

94 

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) 

98 

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) 

104 

105 def target_list(self): 

106 """List all targets""" 

107 return self.parse(self.tgtadm(lld=self.lld, mode="target", op="show").stdout.splitlines()) 

108 

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()) 

112 

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) 

116 

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) 

120 

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) 

124 

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) 

128 

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) 

132 

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) 

136 

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) 

140 

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) 

144 

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) 

150 

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()) 

154 

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) 

160 

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) 

164 

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) 

168 

169 def lld_start(self): 

170 """Start the LLD""" 

171 return self.tgtadm(lld=self.lld, mode="lld", op="start") 

172 

173 def lld_stop(self): 

174 """Stop the LLD""" 

175 return self.tgtadm(lld=self.lld, mode="lld", op="stop") 

176 

177 def sys_show(self): 

178 """Get system information""" 

179 return self.parse(self.tgtadm(mode="sys", op="show").stdout.splitlines()) 

180 

181 def sys_set(self, name, value): 

182 """Set a system parameter""" 

183 return self.tgtadm(mode="sys", op="update", name=name, value=value) 

184 

185 def sys_ready(self): 

186 """Set the system state to ready""" 

187 return self.tgtadm(mode="sys", op="update", name="State", value="ready") 

188 

189 def sys_offline(self): 

190 """Set the system state to offline""" 

191 return self.tgtadm(mode="sys", op="update", name="State", value="offline") 

192 

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 ] 

200 

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)) 

204 

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)) 

208 

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()) 

212 

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) 

216 

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 

252 

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 

258 

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 

266 

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 ) 

300 

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)) 

303 

304 def _find_export(self, fn: Callable): 

305 return next((tgt for tgt in self.export_list() if fn(tgt)), None) 

306 

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 

316 

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) 

323 

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 ) 

370 

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) 

383 

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") 

398 

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") 

416 

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")) 

420 

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")) 

424 

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)