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/__init__.py +15 -0
- dibs/_sources.py +153 -0
- dibs/_sshsig.py +87 -0
- dibs/_store.py +327 -0
- dibs/_users.py +251 -0
- dibs/app.py +344 -0
- dibs/cli.py +727 -0
- pixie_lab_dibs-0.1.0.dist-info/METADATA +253 -0
- pixie_lab_dibs-0.1.0.dist-info/RECORD +12 -0
- pixie_lab_dibs-0.1.0.dist-info/WHEEL +4 -0
- pixie_lab_dibs-0.1.0.dist-info/entry_points.txt +2 -0
- pixie_lab_dibs-0.1.0.dist-info/licenses/LICENSE +28 -0
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())
|