pixie-lab-dibs 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dibs/cli.py ADDED
@@ -0,0 +1,727 @@
1
+ # SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
2
+ #
3
+ # SPDX-License-Identifier: BSD-3-Clause
4
+
5
+ """``dibs``: the one command, for the server and for everyone using it.
6
+
7
+ On the server host: ``dibs serve`` runs the server, ``dibs server
8
+ install`` makes it a systemd service (``start``, ``stop``, ``status``,
9
+ ``logs`` and ``uninstall`` then wrap ``systemctl``), and ``dibs admin``
10
+ approves users by working on the server's database directly.
11
+
12
+ Everywhere else: ``dibs register`` and ``dibs login`` get you a token,
13
+ and ``dibs list``, ``info``, ``claim`` and ``release`` use it. The client
14
+ finds the server with ``--url`` or ``DIBS_URL``, keeps its token per URL
15
+ in ``$XDG_CONFIG_HOME/dibs/client.json`` (``DIBS_TOKEN`` overrides it, for
16
+ runners), and an agent sets ``DIBS_ASSISTED_BY`` (``Agent:model``, as in
17
+ an ``Assisted-by:`` trailer) so its claims say so.
18
+
19
+ Exit status: 0 done, 1 refused because a thing is claimed by someone
20
+ else, 2 anything else (usage, unknown thing, server unreachable).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import re
29
+ import shutil
30
+ import subprocess
31
+ import sys
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ import httpx
36
+ import tomlkit
37
+ import yaml
38
+
39
+ EXIT_REFUSED = 1
40
+ EXIT_ERROR = 2
41
+
42
+ DEFAULT_PORT = 8090
43
+ UNIT_NAME = "dibs.service"
44
+
45
+ _DURATION_RE = re.compile(r"^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$")
46
+ _DEFAULT_KEYS = ("id_ed25519.pub", "id_ecdsa.pub", "id_rsa.pub")
47
+
48
+
49
+ class CliError(Exception):
50
+ pass
51
+
52
+
53
+ class Refused(Exception):
54
+ def __init__(self, sentences: list[str]) -> None:
55
+ self.sentences = sentences
56
+
57
+
58
+ def parse_duration(raw: str) -> int:
59
+ """``2h``, ``90m``, ``1d12h``, ``30s`` -> seconds."""
60
+ m = _DURATION_RE.match(raw.strip())
61
+ if not m or not any(m.groups()):
62
+ raise argparse.ArgumentTypeError(f"{raw!r} is not a duration like 2h, 90m or 1d12h")
63
+ days, hours, minutes, seconds = (int(g or 0) for g in m.groups())
64
+ total = ((days * 24 + hours) * 60 + minutes) * 60 + seconds
65
+ if total <= 0:
66
+ raise argparse.ArgumentTypeError("duration must be longer than zero")
67
+ return total
68
+
69
+
70
+ # ---- server-side paths ---------------------------------------------------
71
+
72
+
73
+ def _xdg(var: str, fallback: str) -> Path:
74
+ return Path(os.environ.get(var) or Path.home() / fallback)
75
+
76
+
77
+ def default_config(user: bool) -> Path:
78
+ """Where the server reads its config. Under systemd that is the
79
+ unit's ``ConfigurationDirectory``, which systemd passes in."""
80
+ if os.environ.get("CONFIGURATION_DIRECTORY"):
81
+ return Path(os.environ["CONFIGURATION_DIRECTORY"]) / "dibs.toml"
82
+ return (
83
+ _xdg("XDG_CONFIG_HOME", ".config") / "dibs" if user else Path("/etc/dibs")
84
+ ) / "dibs.toml"
85
+
86
+
87
+ def default_data(user: bool) -> Path:
88
+ """Where the server keeps its database: the unit's ``StateDirectory``."""
89
+ if os.environ.get("STATE_DIRECTORY"):
90
+ return Path(os.environ["STATE_DIRECTORY"])
91
+ return _xdg("XDG_STATE_HOME", ".local/state") / "dibs" if user else Path("/var/lib/dibs")
92
+
93
+
94
+ # ---- client config -------------------------------------------------------
95
+
96
+
97
+ def config_path() -> Path:
98
+ return _xdg("XDG_CONFIG_HOME", ".config") / "dibs" / "client.json"
99
+
100
+
101
+ def _load_client() -> dict[str, Any]:
102
+ try:
103
+ data = json.loads(config_path().read_text())
104
+ except (OSError, ValueError):
105
+ return {}
106
+ return data if isinstance(data, dict) else {}
107
+
108
+
109
+ def _entry(url: str) -> dict[str, Any]:
110
+ entry = _load_client().get(url.rstrip("/"), {})
111
+ return entry if isinstance(entry, dict) else {}
112
+
113
+
114
+ def _save(url: str, **fields: str | None) -> None:
115
+ """Merge ``fields`` into this URL's entry; ``None`` drops one. The
116
+ file holds a token, so it is created readable by its owner only."""
117
+ data = _load_client()
118
+ entry = data.setdefault(url.rstrip("/"), {})
119
+ for key, value in fields.items():
120
+ if value is None:
121
+ entry.pop(key, None)
122
+ else:
123
+ entry[key] = value
124
+ path = config_path()
125
+ path.parent.mkdir(parents=True, exist_ok=True)
126
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
127
+ with os.fdopen(fd, "w") as f:
128
+ json.dump(data, f, indent=2)
129
+
130
+
131
+ def token(url: str) -> str:
132
+ return os.environ.get("DIBS_TOKEN", "").strip() or str(_entry(url).get("token", ""))
133
+
134
+
135
+ def assisted_by() -> str:
136
+ return os.environ.get("DIBS_ASSISTED_BY", "").strip()
137
+
138
+
139
+ def default_key() -> Path:
140
+ for name in _DEFAULT_KEYS:
141
+ path = Path.home() / ".ssh" / name
142
+ if path.exists():
143
+ return path
144
+ raise CliError("no SSH public key found in ~/.ssh; pass -i PATH.pub")
145
+
146
+
147
+ def sign(key: Path, message: str) -> str:
148
+ """Sign ``message`` in the ``dibs`` namespace. Uses the private key
149
+ beside ``key`` when it exists, else asks the ssh-agent for the key
150
+ whose public half ``key`` is."""
151
+ private = key.with_suffix("") if key.suffix == ".pub" else key
152
+ out = subprocess.run(
153
+ [
154
+ "ssh-keygen",
155
+ "-q",
156
+ "-Y",
157
+ "sign",
158
+ "-n",
159
+ "dibs",
160
+ "-f",
161
+ str(private if private.exists() else key),
162
+ ],
163
+ input=message,
164
+ capture_output=True,
165
+ text=True,
166
+ timeout=120,
167
+ )
168
+ if out.returncode != 0:
169
+ raise CliError(f"ssh-keygen could not sign with {key}: {out.stderr.strip()}")
170
+ return out.stdout
171
+
172
+
173
+ def _request(url: str, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
174
+ headers = {}
175
+ bearer = token(url)
176
+ if bearer:
177
+ headers["Authorization"] = f"Bearer {bearer}"
178
+ try:
179
+ r = httpx.request(method, url.rstrip("/") + path, json=body, headers=headers, timeout=10)
180
+ except httpx.HTTPError as exc:
181
+ raise CliError(f"cannot reach dibs at {url}: {exc}") from exc
182
+ if r.status_code == 409:
183
+ raise Refused(r.json()["detail"]["claimed"])
184
+ if r.status_code >= 400:
185
+ try:
186
+ detail = r.json().get("detail", r.text)
187
+ except ValueError:
188
+ detail = r.text
189
+ raise CliError(str(detail))
190
+ return r.json() if r.content else {}
191
+
192
+
193
+ def dump(data: dict[str, Any], fmt: str) -> str:
194
+ if fmt == "json":
195
+ return json.dumps(data, indent=2) + "\n"
196
+ if fmt == "yaml":
197
+ return str(yaml.safe_dump(data, sort_keys=False, allow_unicode=True))
198
+ return tomlkit.dumps(data)
199
+
200
+
201
+ # ---- client commands -----------------------------------------------------
202
+
203
+
204
+ def _url(args: argparse.Namespace) -> str:
205
+ if not args.url:
206
+ raise CliError("set DIBS_URL (or pass --url), e.g. http://dibs:8090")
207
+ return str(args.url)
208
+
209
+
210
+ def cmd_list(args: argparse.Namespace) -> None:
211
+ url = _url(args)
212
+ things = _request(url, "GET", "/things")["things"]
213
+ if args.names:
214
+ print("\n".join(t["name"] for t in things))
215
+ return
216
+ me = ""
217
+ if args.mine:
218
+ me = _request(url, "GET", "/auth/whoami")["user"]
219
+ things = [t for t in things if t.get("claim", {}).get("holder") == me]
220
+ if args.format:
221
+ sys.stdout.write(dump({"things": things}, args.format))
222
+ return
223
+ if args.mine and not things:
224
+ print(f"'{me}' has no claims")
225
+ for t in things:
226
+ print(t["sentence"])
227
+
228
+
229
+ def humanize_bytes(n: float) -> str:
230
+ size = float(n)
231
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
232
+ if size < 1024 or unit == "TiB":
233
+ return f"{int(size)} {unit}" if unit == "B" else f"{size:,.1f} {unit}"
234
+ size /= 1024
235
+ return f"{size:,.1f} TiB"
236
+
237
+
238
+ def _join(*parts: Any) -> str:
239
+ return " ".join(str(p) for p in parts if p)
240
+
241
+
242
+ def _pick(item: dict[str, Any], *keys: str) -> str:
243
+ return _join(*(item.get(k) for k in keys))
244
+
245
+
246
+ def render_info(info: dict[str, Any]) -> str:
247
+ """One thing as aligned ``label value`` lines; for a pixie machine,
248
+ what pixie knows about it too."""
249
+ rows: list[tuple[str, str]] = [("claim", info["sentence"])]
250
+ rows.append(("source", info["source"]))
251
+ for key, value in info.get("attrs", {}).items():
252
+ rows.append((key, ", ".join(map(str, value)) if isinstance(value, list) else str(value)))
253
+ pixie = info.get("pixie")
254
+ if pixie:
255
+ b = pixie.get("binding", {})
256
+ boot = b.get("boot_mode_label", "")
257
+ image = b.get("image")
258
+ if image:
259
+ boot += f": {image.get('name') or image['sha256'][:12]}"
260
+ if b.get("overlay"):
261
+ boot += f", overlay '{b['overlay']}'"
262
+ rows.append(("boot", boot))
263
+ seen = pixie.get("last_seen", {})
264
+ rows.append(
265
+ ("last seen", _join(seen.get("at"), seen.get("pxe_ip") and f"from {seen['pxe_ip']}"))
266
+ )
267
+ inv = pixie.get("inventory", {})
268
+ system = inv.get("system", {})
269
+ if system:
270
+ firmware = system.get("firmware") and f"firmware {system['firmware']}"
271
+ rows.append(("system", _join(system.get("vendor"), system.get("model"), firmware)))
272
+ for sock in inv.get("cpu", {}).get("sockets", []):
273
+ counts = (
274
+ sock.get("cores")
275
+ and f"({sock['cores']} cores / {sock.get('threads', '?')} threads)"
276
+ )
277
+ rows.append(("cpu", _join(sock.get("model"), counts)))
278
+ mem = inv.get("memory", {})
279
+ if mem.get("total_bytes"):
280
+ slots = (
281
+ mem.get("slots_total") and f"{mem['slots_populated']}/{mem['slots_total']} slots"
282
+ )
283
+ rows.append(
284
+ (
285
+ "memory",
286
+ _join(humanize_bytes(mem["total_bytes"]), slots, mem.get("dominant_type")),
287
+ )
288
+ )
289
+ rows.extend(
290
+ ("nic", _pick(n, "name", "mac", "speed", "driver")) for n in inv.get("nics", [])
291
+ )
292
+ rows.extend(
293
+ ("disk", _pick(d, "path", "size", "vendor", "model", "serial", "tran"))
294
+ for d in inv.get("disks", [])
295
+ )
296
+ if inv:
297
+ rows.append(("inventory", f"taken {inv.get('taken_at', '?')}"))
298
+ width = max(len(label) for label, _ in rows)
299
+ lines = [f"{info['name']} ({info['kind']})"]
300
+ lines += [f" {label.ljust(width)} {value}" for label, value in rows]
301
+ return "\n".join(lines) + "\n"
302
+
303
+
304
+ def cmd_info(args: argparse.Namespace) -> None:
305
+ info = _request(_url(args), "GET", f"/things/{args.thing}")
306
+ sys.stdout.write(dump(info, args.format) if args.format else render_info(info))
307
+
308
+
309
+ def cmd_claim(args: argparse.Namespace) -> None:
310
+ body = {
311
+ "things": args.things,
312
+ "purpose": args.purpose,
313
+ "assisted_by": assisted_by(),
314
+ "expires_in": args.expires,
315
+ "force": args.force,
316
+ }
317
+ for t in _request(_url(args), "POST", "/claims", body)["things"]:
318
+ print(t["sentence"])
319
+
320
+
321
+ def cmd_release(args: argparse.Namespace) -> None:
322
+ if bool(args.things) == args.mine:
323
+ raise CliError("name the things to release, or pass --mine")
324
+ body = {
325
+ "things": args.things,
326
+ "assisted_by": assisted_by(),
327
+ "mine": args.mine,
328
+ "force": args.force,
329
+ }
330
+ released = _request(_url(args), "POST", "/claims/release", body)["things"]
331
+ if not released:
332
+ print("nothing to release")
333
+ for t in released:
334
+ print(t["sentence"])
335
+
336
+
337
+ def cmd_register(args: argparse.Namespace) -> None:
338
+ url = _url(args)
339
+ key = Path(args.key).expanduser() if args.key else default_key()
340
+ try:
341
+ pubkey = key.read_text()
342
+ except OSError as exc:
343
+ raise CliError(f"cannot read {key}: {exc}") from exc
344
+ answer = _request(url, "POST", "/auth/register", {"user": args.user, "pubkey": pubkey})
345
+ _save(url, user=answer["user"], key=str(key), token=None)
346
+ print(f"Registered '{answer['user']}' with key {answer['fingerprint']}.")
347
+ print(f"Ask the dibs admin to run: dibs admin approve {answer['user']}")
348
+ print(f"Then: dibs login {answer['user']}")
349
+
350
+
351
+ def cmd_login(args: argparse.Namespace) -> None:
352
+ url = _url(args)
353
+ entry = _entry(url)
354
+ user = args.user or entry.get("user")
355
+ if not user:
356
+ raise CliError("which user? run: dibs login <user>")
357
+ key = Path(args.key).expanduser() if args.key else Path(entry.get("key") or default_key())
358
+ challenge = _request(url, "POST", "/auth/challenge", {"user": user})
359
+ body = {"user": user, "nonce": challenge["nonce"], "signature": sign(key, challenge["message"])}
360
+ answer = _request(url, "POST", "/auth/login", body)
361
+ _save(url, user=answer["user"], key=str(key), token=answer["token"])
362
+ print(f"Logged in as '{answer['user']}'.")
363
+
364
+
365
+ def cmd_logout(args: argparse.Namespace) -> None:
366
+ url = _url(args)
367
+ if token(url):
368
+ _request(url, "POST", "/auth/logout")
369
+ _save(url, token=None)
370
+ print("Logged out.")
371
+
372
+
373
+ # ---- server commands -----------------------------------------------------
374
+
375
+
376
+ def cmd_serve(args: argparse.Namespace) -> None:
377
+ import logging
378
+
379
+ import uvicorn
380
+
381
+ from dibs.app import create_app
382
+
383
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
384
+ config = Path(args.config) if args.config else default_config(args.user)
385
+ data = Path(args.data) if args.data else default_data(args.user)
386
+ try:
387
+ app = create_app(config, data)
388
+ except ValueError as exc: # a broken config file
389
+ raise CliError(str(exc)) from exc
390
+ uvicorn.run(app, host=args.host, port=args.port)
391
+
392
+
393
+ def _exe() -> Path:
394
+ """The ``dibs`` being run, so the unit starts this same install."""
395
+ found = shutil.which(sys.argv[0]) or sys.argv[0]
396
+ return Path(found).resolve()
397
+
398
+
399
+ def unit_text(exe: Path, *, user: bool, host: str, port: int) -> str:
400
+ serve = f"{exe} serve --host {host} --port {port}"
401
+ if user:
402
+ service = f"""ExecStart={serve} --user
403
+ StateDirectory=dibs
404
+ ConfigurationDirectory=dibs
405
+ Restart=on-failure"""
406
+ target = "default.target"
407
+ else:
408
+ # DynamicUser gives the service a throwaway system user that owns
409
+ # only its StateDirectory, so it needs no account set up.
410
+ service = f"""ExecStart={serve}
411
+ DynamicUser=yes
412
+ StateDirectory=dibs
413
+ ConfigurationDirectory=dibs
414
+ Restart=on-failure
415
+ NoNewPrivileges=yes
416
+ ProtectSystem=strict
417
+ ProtectHome=yes
418
+ PrivateTmp=yes"""
419
+ target = "multi-user.target"
420
+ return f"""# Written by: dibs server install{" --user" if user else ""}
421
+ [Unit]
422
+ Description=dibs: who is using which lab machine
423
+ Documentation=https://github.com/safl/pixie-lab-dibs
424
+ After=network-online.target
425
+ Wants=network-online.target
426
+
427
+ [Service]
428
+ {service}
429
+
430
+ [Install]
431
+ WantedBy={target}
432
+ """
433
+
434
+
435
+ def unit_path(user: bool) -> Path:
436
+ if user:
437
+ return _xdg("XDG_CONFIG_HOME", ".config") / "systemd" / "user" / UNIT_NAME
438
+ return Path("/etc/systemd/system") / UNIT_NAME
439
+
440
+
441
+ def _systemctl(user: bool, *argv: str) -> None:
442
+ cmd = ["systemctl", *(["--user"] if user else []), *argv]
443
+ if subprocess.run(cmd).returncode != 0:
444
+ raise CliError(f"{' '.join(cmd)} failed")
445
+
446
+
447
+ def cmd_server_install(args: argparse.Namespace) -> None:
448
+ exe = _exe()
449
+ if not args.user and str(exe).startswith(str(Path.home())):
450
+ raise CliError(
451
+ f"{exe} is in your home directory, which the system service cannot use. "
452
+ "Install dibs system-wide, or run: dibs server install --user"
453
+ )
454
+ text = unit_text(exe, user=args.user, host=args.host, port=args.port)
455
+ if args.print:
456
+ sys.stdout.write(text)
457
+ return
458
+ path = unit_path(args.user)
459
+ path.parent.mkdir(parents=True, exist_ok=True)
460
+ path.write_text(text)
461
+ _systemctl(args.user, "daemon-reload")
462
+ _systemctl(args.user, "enable", "--now", UNIT_NAME)
463
+ print(f"Installed {path} and started it; dibs listens on port {args.port}.")
464
+ if args.user:
465
+ print("To keep it running while you are logged out: loginctl enable-linger")
466
+
467
+
468
+ def cmd_server_uninstall(args: argparse.Namespace) -> None:
469
+ path = unit_path(args.user)
470
+ _systemctl(args.user, "disable", "--now", UNIT_NAME)
471
+ path.unlink(missing_ok=True)
472
+ _systemctl(args.user, "daemon-reload")
473
+ print(f"Stopped and removed {path}. The database is kept in {default_data(args.user)}.")
474
+
475
+
476
+ def cmd_server_ctl(args: argparse.Namespace) -> None:
477
+ if args.verb == "logs":
478
+ cmd = ["journalctl", *(["--user"] if args.user else []), "-u", UNIT_NAME, "-f"]
479
+ subprocess.run(cmd)
480
+ return
481
+ _systemctl(args.user, args.verb, UNIT_NAME)
482
+
483
+
484
+ # ---- admin commands ------------------------------------------------------
485
+
486
+
487
+ def _users(args: argparse.Namespace) -> Any:
488
+ from dibs._users import UsersStore
489
+
490
+ data = Path(args.data) if args.data else default_data(args.user)
491
+ db = data / "dibs.db"
492
+ if not db.exists():
493
+ raise CliError(f"no dibs database at {db}; is the server installed here? (--data PATH)")
494
+ return UsersStore(db)
495
+
496
+
497
+ def cmd_admin_users(args: argparse.Namespace) -> None:
498
+ users = _users(args).list_users()
499
+ if not users:
500
+ print("no users")
501
+ for u in users:
502
+ print(f"{u.name:<20} {u.status:<9} {u.fingerprint} tokens: {u.tokens}")
503
+
504
+
505
+ def cmd_admin_approve(args: argparse.Namespace) -> None:
506
+ try:
507
+ _users(args).approve(args.name)
508
+ except LookupError as exc:
509
+ raise CliError(str(exc)) from exc
510
+ print(f"Approved '{args.name}'; they can run: dibs login {args.name}")
511
+
512
+
513
+ def cmd_admin_remove(args: argparse.Namespace) -> None:
514
+ if not _users(args).remove(args.name):
515
+ raise CliError(f"no user {args.name!r}")
516
+ print(f"Removed '{args.name}' and their tokens.")
517
+
518
+
519
+ def cmd_admin_revoke(args: argparse.Namespace) -> None:
520
+ count = _users(args).revoke_tokens(args.name)
521
+ print(f"Revoked {count} token(s) of '{args.name}'; they must log in again.")
522
+
523
+
524
+ # ---- shell completion ------------------------------------------------------
525
+
526
+ # Commands whose positional arguments are thing names, which the
527
+ # completion fetches from the server.
528
+ _THING_COMMANDS = ("info", "claim", "release")
529
+
530
+
531
+ def _walk(parser: argparse.ArgumentParser, path: str, out: dict[str, Any]) -> None:
532
+ children: list[str] = []
533
+ opts: list[str] = []
534
+ for action in parser._actions:
535
+ if isinstance(action, argparse._SubParsersAction):
536
+ for name, child in action.choices.items():
537
+ children.append(name)
538
+ _walk(child, f"{path} {name}", out)
539
+ elif action.option_strings and action.help != argparse.SUPPRESS:
540
+ opts.extend(o for o in action.option_strings if o.startswith("--"))
541
+ out[path] = (children, opts)
542
+
543
+
544
+ def completion_bash(parser: argparse.ArgumentParser) -> str:
545
+ """A bash completion script, generated from the parser so it cannot
546
+ drift from the commands it completes."""
547
+ # Rooted at "dibs" because bash refuses an empty associative-array key.
548
+ tree: dict[str, Any] = {}
549
+ _walk(parser, "dibs", tree)
550
+ children = "\n".join(f' ["{p}"]="{" ".join(c)}"' for p, (c, _) in tree.items())
551
+ opts = "\n".join(f' ["{p}"]="{" ".join(o)}"' for p, (_, o) in tree.items())
552
+ things = "|".join(f"dibs {c}" for c in _THING_COMMANDS)
553
+ return f"""# bash completion for dibs, from: dibs completion bash
554
+ declare -gA _dibs_children=(
555
+ {children}
556
+ )
557
+ declare -gA _dibs_opts=(
558
+ {opts}
559
+ )
560
+ _dibs() {{
561
+ local cur=${{COMP_WORDS[COMP_CWORD]}} path=dibs word i
562
+ for ((i = 1; i < COMP_CWORD; i++)); do
563
+ word=${{COMP_WORDS[i]}}
564
+ [[ " ${{_dibs_children[$path]}} " == *" $word "* ]] && path="$path $word"
565
+ done
566
+ if [[ $cur == -* ]]; then
567
+ COMPREPLY=($(compgen -W "${{_dibs_opts[$path]}}" -- "$cur"))
568
+ elif [[ -n ${{_dibs_children[$path]}} ]]; then
569
+ COMPREPLY=($(compgen -W "${{_dibs_children[$path]}}" -- "$cur"))
570
+ elif [[ "|{things}|" == *"|$path|"* ]]; then
571
+ COMPREPLY=($(compgen -W "$(timeout 1 dibs list --names 2>/dev/null)" -- "$cur"))
572
+ fi
573
+ }}
574
+ complete -o default -F _dibs dibs
575
+ """
576
+
577
+
578
+ def cmd_completion(args: argparse.Namespace) -> None:
579
+ sys.stdout.write(completion_bash(build_parser()))
580
+
581
+
582
+ # ---- parser --------------------------------------------------------------
583
+
584
+
585
+ def build_parser() -> argparse.ArgumentParser:
586
+ parser = argparse.ArgumentParser(
587
+ prog="dibs",
588
+ description=(
589
+ "Call dibs on shared lab machines. See who is using what, claim it, release "
590
+ "it. A claim is a note for everyone else, not a lock, and it never expires "
591
+ "unless you say so."
592
+ ),
593
+ epilog=(
594
+ "The client finds the server via --url or DIBS_URL. Claiming needs a login: "
595
+ "register, have the admin approve you, then login. Agents set "
596
+ "DIBS_ASSISTED_BY; runners may pass a token in DIBS_TOKEN. Exit status: 0 done, "
597
+ "1 claimed by someone else, 2 other errors."
598
+ ),
599
+ )
600
+ parser.add_argument("--url", default=os.environ.get("DIBS_URL", ""), help="dibs server URL")
601
+ sub = parser.add_subparsers(dest="cmd", required=True, metavar="command")
602
+
603
+ data = argparse.ArgumentParser(add_help=False)
604
+ group = data.add_mutually_exclusive_group()
605
+ for fmt in ("json", "yaml", "toml"):
606
+ group.add_argument(
607
+ f"--{fmt}",
608
+ dest="format",
609
+ action="store_const",
610
+ const=fmt,
611
+ help=f"print the answer as {fmt.upper()}, for scripts and agents",
612
+ )
613
+
614
+ p = sub.add_parser("list", parents=[data], help="show every thing, free or claimed")
615
+ p.add_argument("--mine", action="store_true", help="only the things you have claimed")
616
+ # For the shell completion: thing names, one per line.
617
+ p.add_argument("--names", action="store_true", help=argparse.SUPPRESS)
618
+ p.set_defaults(func=cmd_list)
619
+
620
+ p = sub.add_parser("info", parents=[data], help="show one thing, and what pixie knows of it")
621
+ p.add_argument("thing")
622
+ p.set_defaults(func=cmd_info)
623
+
624
+ p = sub.add_parser("claim", help="claim things, all or none")
625
+ p.add_argument("things", nargs="+", metavar="thing")
626
+ p.add_argument("--for", dest="purpose", required=True, help="what the claim is for")
627
+ p.add_argument(
628
+ "--expires",
629
+ type=parse_duration,
630
+ default=None,
631
+ metavar="DURATION",
632
+ help="e.g. 2h, 90m, 1d12h (default: never)",
633
+ )
634
+ p.add_argument("--force", action="store_true", help="take things claimed by someone else")
635
+ p.set_defaults(func=cmd_claim)
636
+
637
+ p = sub.add_parser("release", help="release things, or all of yours with --mine")
638
+ p.add_argument("things", nargs="*", metavar="thing")
639
+ p.add_argument("--mine", action="store_true", help="release everything you have claimed")
640
+ p.add_argument("--force", action="store_true", help="release someone else's claim")
641
+ p.set_defaults(func=cmd_release)
642
+
643
+ p = sub.add_parser("register", help="ask the admin for a user, with an SSH public key")
644
+ p.add_argument("user")
645
+ p.add_argument("-i", "--key", help="SSH public key file (default: ~/.ssh/id_ed25519.pub, ...)")
646
+ p.set_defaults(func=cmd_register)
647
+
648
+ p = sub.add_parser("login", help="sign in with your SSH key and keep the token")
649
+ p.add_argument("user", nargs="?", help="default: the user you registered or last logged in as")
650
+ p.add_argument("-i", "--key", help="SSH public key file (default: the one you registered with)")
651
+ p.set_defaults(func=cmd_login)
652
+
653
+ p = sub.add_parser("logout", help="drop your token for this server")
654
+ p.set_defaults(func=cmd_logout)
655
+
656
+ def user_mode(p: argparse.ArgumentParser) -> None:
657
+ p.add_argument(
658
+ "--user", action="store_true", help="the per-user service rather than the system one"
659
+ )
660
+
661
+ p = sub.add_parser("serve", help="run the server in the foreground")
662
+ p.add_argument("--host", default=os.environ.get("DIBS_HOST", "0.0.0.0"))
663
+ p.add_argument("--port", type=int, default=int(os.environ.get("DIBS_PORT", DEFAULT_PORT)))
664
+ p.add_argument("--config", help="default: /etc/dibs/dibs.toml (with --user: ~/.config/dibs/)")
665
+ p.add_argument("--data", help="default: /var/lib/dibs (with --user: ~/.local/state/dibs)")
666
+ user_mode(p)
667
+ p.set_defaults(func=cmd_serve)
668
+
669
+ server = sub.add_parser("server", help="run dibs as a systemd service")
670
+ ssub = server.add_subparsers(dest="verb", required=True, metavar="action")
671
+ p = ssub.add_parser("install", help="write the unit for this dibs, enable and start it")
672
+ p.add_argument("--host", default="0.0.0.0")
673
+ p.add_argument("--port", type=int, default=DEFAULT_PORT)
674
+ p.add_argument("--print", action="store_true", help="only print the unit")
675
+ user_mode(p)
676
+ p.set_defaults(func=cmd_server_install)
677
+ p = ssub.add_parser("uninstall", help="stop the service and remove its unit")
678
+ user_mode(p)
679
+ p.set_defaults(func=cmd_server_uninstall)
680
+ for verb, text in (
681
+ ("start", "start the service"),
682
+ ("stop", "stop the service"),
683
+ ("restart", "restart it, e.g. after editing dibs.toml"),
684
+ ("status", "show whether it is running"),
685
+ ("logs", "follow its log"),
686
+ ):
687
+ p = ssub.add_parser(verb, help=text)
688
+ user_mode(p)
689
+ p.set_defaults(func=cmd_server_ctl)
690
+
691
+ admin = sub.add_parser("admin", help="approve and manage users, on the server host")
692
+ asub = admin.add_subparsers(dest="action", required=True, metavar="action")
693
+ for action, text, func, takes_name in (
694
+ ("users", "list users and whether they are approved", cmd_admin_users, False),
695
+ ("approve", "let a registered user log in", cmd_admin_approve, True),
696
+ ("remove", "reject or remove a user, with their tokens", cmd_admin_remove, True),
697
+ ("revoke", "end every login a user has", cmd_admin_revoke, True),
698
+ ):
699
+ p = asub.add_parser(action, help=text)
700
+ if takes_name:
701
+ p.add_argument("name")
702
+ p.add_argument("--data", help="the server's data directory (default: /var/lib/dibs)")
703
+ user_mode(p)
704
+ p.set_defaults(func=func)
705
+
706
+ p = sub.add_parser("completion", help="print a shell completion script")
707
+ p.add_argument("shell", choices=["bash"])
708
+ p.set_defaults(func=cmd_completion)
709
+ return parser
710
+
711
+
712
+ def main(argv: list[str] | None = None) -> int:
713
+ args = build_parser().parse_args(argv)
714
+ try:
715
+ args.func(args)
716
+ except Refused as exc:
717
+ for sentence in exc.sentences:
718
+ print(f"dibs: refused, {sentence}", file=sys.stderr)
719
+ return EXIT_REFUSED
720
+ except CliError as exc:
721
+ print(f"dibs: {exc}", file=sys.stderr)
722
+ return EXIT_ERROR
723
+ return 0
724
+
725
+
726
+ if __name__ == "__main__":
727
+ sys.exit(main())