Coverage for volexport/client.py: 81%
374 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
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
11_log = getLogger(__name__)
14class VERequest(requests.Session):
15 def __init__(self, baseurl: str):
16 super().__init__()
17 self.baseurl = baseurl
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
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)
54 return click.option(
55 "--endpoint", envvar="VOLEXP_ENDPOINT", default="http://localhost:8000", show_default=True, show_envvar=True
56 )(wrap)
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())
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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)
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()
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()
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()
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()
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)
400def find_device(name, wait: int = 0):
401 import glob
402 import time
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
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
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()
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
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"""
474 devname = find_device(name)
475 if devname is None:
476 raise FileNotFoundError(f"volume not attached: {name=}")
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=}")
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
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")
505 final_res = req.delete(f"/export/{targetname}", params=dict(force="1"))
506 return final_res.json()
509if __name__ == "__main__": 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 cli()