Coverage for volexport/client.py: 81%

374 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-09-28 12:48 +0000

1import click 

2import requests 

3import functools 

4from pathlib import Path 

5from urllib.parse import urljoin, urlparse 

6from logging import getLogger 

7from .cli_utils import verbose_option, SizeType, output_format 

8from .util import runcmd 

9from .version import VERSION 

10 

11_log = getLogger(__name__) 

12 

13 

14class VERequest(requests.Session): 

15 def __init__(self, baseurl: str): 

16 super().__init__() 

17 self.baseurl = baseurl 

18 

19 def request(self, method, path, *args, **kwargs): 

20 url = urljoin(self.baseurl.removesuffix("/") + "/", path.removeprefix("/")) 

21 _log.debug("request: method=%s url=%s args=%s", method, url, kwargs.get("json") or kwargs.get("data")) 

22 res = super().request(method, url, *args, **kwargs) 

23 try: 

24 _log.debug( 

25 "response(json): elapsed=%s method=%s url=%s code=%s, body=%s", 

26 res.elapsed, 

27 method, 

28 url, 

29 res.status_code, 

30 res.json(), 

31 ) 

32 if res.status_code == requests.codes.unprocessable: 

33 errs = res.json().get("detail", []) 

34 for err in errs: 

35 _log.warning("validation error at %s: %s", ".".join(err.get("loc", [])), err.get("msg")) 

36 except Exception: 

37 _log.debug( 

38 "response(text): elapsed=%s method=%s url=%s code=%s, body=%s", 

39 res.elapsed, 

40 method, 

41 url, 

42 res.status_code, 

43 res.text, 

44 ) 

45 return res 

46 

47 

48def client_option(func): 

49 @functools.wraps(func) 

50 def wrap(endpoint, *args, **kwargs): 

51 req = VERequest(endpoint) 

52 return func(req=req, *args, **kwargs) 

53 

54 return click.option( 

55 "--endpoint", envvar="VOLEXP_ENDPOINT", default="http://localhost:8000", show_default=True, show_envvar=True 

56 )(wrap) 

57 

58 

59@click.version_option(version=VERSION, prog_name="volexport-client") 

60@click.group(invoke_without_command=True) 

61@click.pass_context 

62def cli(ctx): 

63 if ctx.invoked_subcommand is None: 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true

64 click.echo(ctx.get_help()) 

65 

66 

67@cli.command() 

68@verbose_option 

69@client_option 

70@output_format 

71def volume_list(req): 

72 """list volumes""" 

73 res = req.get("/volume") 

74 res.raise_for_status() 

75 return res.json() 

76 

77 

78@cli.command() 

79@verbose_option 

80@client_option 

81@output_format 

82def volume_stats(req): 

83 """show volume stats""" 

84 res = req.get("/stats/volume") 

85 res.raise_for_status() 

86 return res.json() 

87 

88 

89@cli.command() 

90@verbose_option 

91@client_option 

92@output_format 

93@click.option("--name", required=True, help="volume name") 

94@click.option("--size", type=SizeType(), help="volume size", required=True) 

95def volume_create(req, name, size): 

96 """create new volume""" 

97 res = req.post("/volume", json=dict(name=name, size=size)) 

98 res.raise_for_status() 

99 return res.json() 

100 

101 

102@cli.command() 

103@verbose_option 

104@client_option 

105@output_format 

106@click.option("--name", required=True, help="volume name") 

107def volume_read(req, name): 

108 """show volume info""" 

109 res = req.get(f"/volume/{name}") 

110 res.raise_for_status() 

111 return res.json() 

112 

113 

114@cli.command() 

115@verbose_option 

116@client_option 

117@output_format 

118@click.option("--name", required=True, help="volume name") 

119@click.option("--readonly/--readwrite", help="ro/rw", default=True, show_default=True) 

120def volume_readonly(req, name, readonly): 

121 """set volume readonly/readwrite""" 

122 res = req.post(f"/volume/{name}", json=dict(readonly=readonly)) 

123 res.raise_for_status() 

124 return res.json() 

125 

126 

127@cli.command() 

128@verbose_option 

129@client_option 

130@output_format 

131@click.option("--name", required=True, help="volume name") 

132@click.option("--size", type=SizeType(), help="volume size", required=True) 

133def volume_resize(req, name, size): 

134 """resize volume""" 

135 res = req.post(f"/volume/{name}", json=dict(size=size)) 

136 res.raise_for_status() 

137 return res.json() 

138 

139 

140@cli.command() 

141@verbose_option 

142@client_option 

143@output_format 

144@click.option("--name", required=True, help="volume name") 

145@click.option("--filesystem", default="ext4", help="filesystem") 

146@click.option("--label") 

147def volume_mkfs(req, name, filesystem, label): 

148 """mkfs volume""" 

149 res = req.post(f"/volume/{name}/mkfs", json=dict(filesystem=filesystem, label=label)) 

150 res.raise_for_status() 

151 return res.json() 

152 

153 

154@cli.command() 

155@verbose_option 

156@client_option 

157@output_format 

158@click.option("--name", required=True, help="volume name") 

159def volume_delete(req, name): 

160 """delete volume""" 

161 res = req.delete(f"/volume/{name}") 

162 res.raise_for_status() 

163 return res.json() 

164 

165 

166@cli.command() 

167@verbose_option 

168@client_option 

169@output_format 

170@click.option("--name", required=True, help="volume name") 

171@click.option("--parent", required=True, help="parent volume name") 

172@click.option("--size", type=SizeType(), help="volume size") 

173def snapshot_create(req, name, parent, size): 

174 """create snapshot""" 

175 res = req.post(f"/volume/{parent}/snapshot", json=dict(name=name, size=size)) 

176 res.raise_for_status() 

177 return res.json() 

178 

179 

180@cli.command() 

181@verbose_option 

182@client_option 

183@output_format 

184@click.option("--parent", required=True, help="parent volume name") 

185def snapshot_list(req, parent): 

186 """list snapshot""" 

187 res = req.get(f"/volume/{parent}/snapshot") 

188 res.raise_for_status() 

189 return res.json() 

190 

191 

192@cli.command() 

193@verbose_option 

194@client_option 

195@output_format 

196@click.option("--name", required=True, help="snapshot name") 

197@click.option("--parent", required=True, help="parent volume name") 

198def snapshot_get(req, name, parent): 

199 """get snapshot info""" 

200 res = req.get(f"/volume/{parent}/snapshot/{name}") 

201 res.raise_for_status() 

202 return res.json() 

203 

204 

205@cli.command() 

206@verbose_option 

207@client_option 

208@output_format 

209@click.option("--name", required=True, help="snapshot name") 

210@click.option("--parent", required=True, help="parent volume name") 

211def snapshot_delete(req, name, parent): 

212 """delete snapshot""" 

213 res = req.delete(f"/volume/{parent}/snapshot/{name}") 

214 res.raise_for_status() 

215 return res.json() 

216 

217 

218@cli.command() 

219@verbose_option 

220@client_option 

221@output_format 

222def export_list(req): 

223 """list exports""" 

224 res = req.get("/export") 

225 res.raise_for_status() 

226 return res.json() 

227 

228 

229@cli.command() 

230@verbose_option 

231@client_option 

232@output_format 

233def export_stats(req): 

234 """show export stats""" 

235 res = req.get("/stats/export") 

236 res.raise_for_status() 

237 return res.json() 

238 

239 

240@cli.command() 

241@verbose_option 

242@client_option 

243@output_format 

244@click.option("--name", required=True, help="volume name") 

245@click.option("--show-command/--no-command", default=True, show_default=True) 

246@click.option("--acl", multiple=True, help="access control list") 

247def export_create(req: VERequest, name, acl, show_command): 

248 """create new export""" 

249 res = req.post("/export", json=dict(name=name, acl=list(acl))) 

250 res.raise_for_status() 

251 if show_command: 251 ↛ 266line 251 didn't jump to line 266 because the condition on line 251 was always true

252 data = res.json() 

253 addrs = data["addresses"] 

254 if addrs: 254 ↛ 257line 254 didn't jump to line 257 because the condition on line 254 was always true

255 addr = addrs[0] 

256 else: 

257 _log.warning("volexp returns no ip address.") 

258 addr = urlparse(req.baseurl).hostname 

259 click.echo(f""" 

260iscsiadm -m discovery -t st -p {addr} 

261iscsiadm -m node -T {data["targetname"]} -o update -n node.session.auth.authmethod -v CHAP 

262iscsiadm -m node -T {data["targetname"]} -o update -n node.session.auth.username -v {data["user"]} 

263iscsiadm -m node -T {data["targetname"]} -o update -n node.session.auth.password -v {data["passwd"]} 

264iscsiadm -m node -T {data["targetname"]} -l 

265""") 

266 return res.json() 

267 

268 

269@cli.command() 

270@verbose_option 

271@client_option 

272@output_format 

273@click.option("--targetname", required=True, help="target name") 

274def export_read(req, targetname): 

275 """show export info""" 

276 res = req.get(f"/export/{targetname}") 

277 res.raise_for_status() 

278 return res.json() 

279 

280 

281@cli.command() 

282@verbose_option 

283@client_option 

284@output_format 

285@click.option("--force/--no-force", default=False, show_default=True, help="force delete export") 

286@click.option("--targetname", required=True, help="target name") 

287def export_delete(req: VERequest, targetname, force): 

288 """delete export""" 

289 param = dict(force="1") if force else dict() 

290 res = req.delete(f"/export/{targetname}", params=param) 

291 res.raise_for_status() 

292 return res.json() 

293 

294 

295@cli.command() 

296@verbose_option 

297@client_option 

298@output_format 

299def address(req): 

300 """show address""" 

301 res = req.get("/address") 

302 res.raise_for_status() 

303 return res.json() 

304 

305 

306@cli.command() 

307@verbose_option 

308@client_option 

309@output_format 

310def backup_list(req): 

311 """show backups""" 

312 res = req.get("/mgmt/backup") 

313 res.raise_for_status() 

314 return res.json() 

315 

316 

317@cli.command() 

318@verbose_option 

319@client_option 

320@output_format 

321def backup_create(req): 

322 """create backups""" 

323 res = req.post("/mgmt/backup") 

324 res.raise_for_status() 

325 return res.json() 

326 

327 

328@cli.command() 

329@verbose_option 

330@client_option 

331@click.option("--name", required=True, help="name of backup") 

332def backup_read(req, name): 

333 """read backups""" 

334 res = req.get(f"/mgmt/backup/{name}") 

335 res.raise_for_status() 

336 click.echo(res.text, nl=False) 

337 

338 

339@cli.command() 

340@verbose_option 

341@client_option 

342@output_format 

343@click.option("--name", required=True, help="name of backup") 

344def backup_restore(req, name): 

345 """read backups""" 

346 res = req.post(f"/mgmt/backup/{name}") 

347 res.raise_for_status() 

348 return res.json() 

349 

350 

351@cli.command() 

352@verbose_option 

353@client_option 

354@output_format 

355@click.option("--name", required=True, help="name of backup") 

356@click.option("--input", type=click.File("r")) 

357def backup_put(req, name, input): 

358 """put saved backups""" 

359 res = req.put(f"/mgmt/backup/{name}", data=input.read()) 

360 res.raise_for_status() 

361 return res.json() 

362 

363 

364@cli.command() 

365@verbose_option 

366@client_option 

367@output_format 

368@click.option("--keep", type=int, default=2, show_default=True) 

369def backup_forget(req, keep): 

370 """forget old backups""" 

371 res = req.delete("/mgmt/backup", params=dict(keep=keep)) 

372 res.raise_for_status() 

373 return res.json() 

374 

375 

376@cli.command() 

377@verbose_option 

378@client_option 

379@output_format 

380@click.option("--name", required=True, help="name of backup") 

381def backup_delete(req, name): 

382 """delete old backups""" 

383 res = req.delete(f"/mgmt/backup/{name}") 

384 res.raise_for_status() 

385 return res.json() 

386 

387 

388def iscsiadm(**kwargs): 

389 arg = ["iscsiadm"] 

390 for k, v in kwargs.items(): 

391 if len(k) == 1: 391 ↛ 394line 391 didn't jump to line 394 because the condition on line 391 was always true

392 arg.append(f"-{k}") 

393 else: 

394 arg.append(f"--{k}") 

395 if v is not None: 

396 arg.append(v) 

397 return runcmd(arg, root=True) 

398 

399 

400def find_device(name, wait: int = 0): 

401 import glob 

402 import time 

403 

404 for _ in range(wait + 1): 

405 for model in glob.glob("/sys/block/sd*/device/model"): 

406 p = Path(model) 

407 if p.read_text().strip() == name and (p.parent / "vendor").read_text().strip() == "VOLEXP": 

408 devname = p.parent.parent.name 

409 return f"/dev/{devname}" 

410 else: 

411 _log.info("wait and retry") 

412 time.sleep(2) 

413 return None 

414 

415 

416@cli.command() 

417@verbose_option 

418@client_option 

419@click.option("--name", required=True, help="volume name") 

420@click.option("--format/--no-format", default=False, show_default=True) 

421@click.option("--mount") 

422def attach_volume(req: VERequest, name, format, mount): 

423 """attach volume""" 

424 import ifaddr 

425 

426 if format: 426 ↛ 427line 426 didn't jump to line 427 because the condition on line 426 was never true

427 res = req.post(f"/volume/{name}/mkfs", json=dict(filesystem="ext4")) 

428 res.raise_for_status() 

429 

430 addrs = [] 

431 for ad in ifaddr.get_adapters(): 

432 for ip in ad.ips: 

433 if isinstance(ip.ip, tuple): 

434 if ip.ip[2] != 0: 

435 # scope id (is link local address) 

436 continue 

437 addr = ip.ip[0] 

438 else: 

439 addr = ip.ip 

440 if addr: 440 ↛ 432line 440 didn't jump to line 432 because the condition on line 440 was always true

441 addrs.append(addr) 

442 res = req.post("/export", json=dict(name=name, acl=list(addrs))) 

443 res.raise_for_status() 

444 data = res.json() 

445 addrs: list[str] = data["addresses"] 

446 if addrs: 446 ↛ 449line 446 didn't jump to line 449 because the condition on line 446 was always true

447 tgtaddr = addrs[0] 

448 else: 

449 _log.warning("volexp returns no ip address.") 

450 tgtaddr = urlparse(req.baseurl).hostname 

451 assert tgtaddr is not None 

452 targetname: str = data["targetname"] 

453 iscsiadm(m="discovery", t="st", p=tgtaddr) 

454 iscsiadm(m="node", T=targetname, o="update", n="node.session.auth.authmethod", v="CHAP") 

455 iscsiadm(m="node", T=targetname, o="update", n="node.session.auth.username", v=data["user"]) 

456 iscsiadm(m="node", T=targetname, o="update", n="node.session.auth.password", v=data["passwd"]) 

457 iscsiadm(m="node", T=targetname, l=None) 

458 if mount: 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true

459 devname = find_device(name, 10) 

460 if devname is not None: 

461 runcmd(["mount", devname, mount], root=True) 

462 else: 

463 raise Exception(f"volume not found: {name=}") 

464 return 

465 

466 

467@cli.command() 

468@verbose_option 

469@client_option 

470@click.option("--name", help="volume name") 

471def detach_volume(req: VERequest, name): 

472 """detach volume""" 

473 

474 devname = find_device(name) 

475 if devname is None: 

476 raise FileNotFoundError(f"volume not attached: {name=}") 

477 

478 # find target 

479 res = req.get("/export") 

480 res.raise_for_status() 

481 for tgt in res.json(): 

482 if tgt["volumes"] == [name]: 

483 targetname = tgt["targetname"] 

484 break 

485 else: 

486 raise FileNotFoundError(f"target not found: {name=}") 

487 

488 # umount if mounted 

489 for line in Path("/proc/mounts").read_text().splitlines(): 

490 words = line.split() 

491 if words[0] == devname: 

492 _log.info("device %s mounted %s", words[0], words[1]) 

493 runcmd(["umount", words[0]], root=True) 

494 break 

495 

496 res = iscsiadm(m="node", T=targetname, u=None) 

497 portal = None 

498 for line in res.stdout.splitlines(): 

499 if line.endswith("successful."): 

500 portal = line.split("[", 1)[-1].split("]", 1)[0].rsplit(maxsplit=1)[-1] 

501 break 

502 if portal: 

503 iscsiadm(m="discoverydb", t="st", p=portal, o="delete") 

504 

505 final_res = req.delete(f"/export/{targetname}", params=dict(force="1")) 

506 return final_res.json() 

507 

508 

509if __name__ == "__main__": 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true

510 cli()