Coverage for volexport/tgtd.py: 81%

256 statements  

« 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 

11 

12_log = getLogger(__name__) 

13 

14 

15class Tgtd: 

16 def __init__(self): 

17 self.lld = "iscsi" 

18 

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

46 

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 

68 

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) 

82 

83 def target_create(self, tid: int, name: str): 

84 return self.tgtadm(lld=self.lld, mode="target", op="new", tid=tid, targetname=name) 

85 

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) 

90 

91 def target_list(self): 

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

93 

94 def target_show(self, tid: int): 

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

96 

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) 

99 

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) 

102 

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) 

105 

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) 

108 

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) 

111 

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) 

114 

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) 

117 

118 def lun_delete(self, tid: int, lun: int): 

119 return self.tgtadm(lld=self.lld, mode="logicalunit", op="delete", tid=tid, lun=lun) 

120 

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) 

125 

126 def account_list(self): 

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

128 

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) 

133 

134 def account_bind(self, tid: int, user: str): 

135 return self.tgtadm(lld=self.lld, mode="account", op="bind", tid=tid, user=user) 

136 

137 def account_unbind(self, tid: int, user: str): 

138 return self.tgtadm(lld=self.lld, mode="account", op="unbind", tid=tid, user=user) 

139 

140 def lld_start(self): 

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

142 

143 def lld_stop(self): 

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

145 

146 def sys_show(self): 

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

148 

149 def sys_set(self, name, value): 

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

151 

152 def sys_ready(self): 

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

154 

155 def sys_offline(self): 

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

157 

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 ] 

164 

165 def portal_add(self, hostport): 

166 return self.tgtadm(lld=self.lld, mode="portal", op="new", param=dict(portal=hostport)) 

167 

168 def portal_delete(self, hostport): 

169 return self.tgtadm(lld=self.lld, mode="portal", op="delete", param=dict(portal=hostport)) 

170 

171 def list_session(self, tid: int): 

172 return self.parse(self.tgtadm(lld=self.lld, mode="conn", op="show", tid=tid).stdout.splitlines()) 

173 

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) 

176 

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 

209 

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 

251 

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 ) 

288 

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 

294 

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 

300 

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