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/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
4
|
+
|
|
5
|
+
"""dibs: call dibs on shared lab machines.
|
|
6
|
+
|
|
7
|
+
People, ai-agents and automation-runners share lab machines. dibs keeps
|
|
8
|
+
a list of the things they share, fed from sources such as a config file
|
|
9
|
+
or a pixie server, and lets each of them see who is using a thing,
|
|
10
|
+
claim it, and release it. A claim is a note, not a lock: it tells
|
|
11
|
+
everyone who is on a machine, and it never stops anyone from logging
|
|
12
|
+
in.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
dibs/_sources.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
4
|
+
|
|
5
|
+
"""Where things come from: the config file, and pixie servers.
|
|
6
|
+
|
|
7
|
+
The config file (``/etc/dibs/dibs.toml`` by default) lists things by
|
|
8
|
+
hand and names the pixie servers to poll::
|
|
9
|
+
|
|
10
|
+
sync_interval = 60 # seconds between syncs
|
|
11
|
+
|
|
12
|
+
[[pixie]]
|
|
13
|
+
url = "http://pixie:8080" # every machine pixie knows becomes a thing
|
|
14
|
+
|
|
15
|
+
[[thing]]
|
|
16
|
+
name = "ryzen-01" # a machine pixie does not boot
|
|
17
|
+
kind = "machine" # default
|
|
18
|
+
mac = "aa:bb:cc:dd:ee:ff" # any other key is kept as an attribute
|
|
19
|
+
|
|
20
|
+
A ``[[thing]]`` with the name of a pixie machine adds its attributes to
|
|
21
|
+
that machine rather than making a second thing.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import tomllib
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
|
|
35
|
+
from dibs._store import Store, Thing, ts, validate_thing_name
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger("dibs")
|
|
38
|
+
|
|
39
|
+
CONFIG_SOURCE = "config"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Config:
|
|
44
|
+
things: list[Thing] = field(default_factory=list)
|
|
45
|
+
pixies: list[str] = field(default_factory=list)
|
|
46
|
+
sync_interval: int = 60
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_config(path: Path) -> Config:
|
|
50
|
+
"""Read the config file; a missing file is an empty config, so a
|
|
51
|
+
fresh install starts with no things rather than failing."""
|
|
52
|
+
if not path.exists():
|
|
53
|
+
return Config()
|
|
54
|
+
try:
|
|
55
|
+
raw = tomllib.loads(path.read_text())
|
|
56
|
+
except tomllib.TOMLDecodeError as exc:
|
|
57
|
+
raise ValueError(f"{path}: {exc}") from exc
|
|
58
|
+
interval = raw.get("sync_interval", 60)
|
|
59
|
+
if not isinstance(interval, int) or interval < 5:
|
|
60
|
+
raise ValueError(f"{path}: sync_interval must be a whole number of seconds, at least 5")
|
|
61
|
+
pixies = []
|
|
62
|
+
for i, entry in enumerate(raw.get("pixie", [])):
|
|
63
|
+
url = entry.get("url") if isinstance(entry, dict) else None
|
|
64
|
+
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
|
65
|
+
raise ValueError(f'{path}: pixie #{i + 1} needs a url = "http://..."')
|
|
66
|
+
pixies.append(url.rstrip("/"))
|
|
67
|
+
things = []
|
|
68
|
+
seen: set[str] = set()
|
|
69
|
+
for i, entry in enumerate(raw.get("thing", [])):
|
|
70
|
+
if not isinstance(entry, dict) or "name" not in entry:
|
|
71
|
+
raise ValueError(f"{path}: thing #{i + 1} needs a name")
|
|
72
|
+
attrs = dict(entry)
|
|
73
|
+
try:
|
|
74
|
+
name = validate_thing_name(str(attrs.pop("name")))
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise ValueError(f"{path}: thing #{i + 1}: {exc}") from exc
|
|
77
|
+
if name in seen:
|
|
78
|
+
raise ValueError(f"{path}: thing {name!r} is listed twice")
|
|
79
|
+
seen.add(name)
|
|
80
|
+
kind = str(attrs.pop("kind", "machine"))
|
|
81
|
+
things.append(Thing(name, kind, CONFIG_SOURCE, attrs))
|
|
82
|
+
return Config(things, pixies, interval)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PixieSource:
|
|
86
|
+
"""A pixie server's machines, as things of kind ``machine``."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, url: str, client: httpx.Client | None = None) -> None:
|
|
89
|
+
self.url = url.rstrip("/")
|
|
90
|
+
self.id = f"pixie:{self.url}"
|
|
91
|
+
self._client = client or httpx.Client(timeout=10)
|
|
92
|
+
|
|
93
|
+
def fetch(self) -> list[Thing] | None:
|
|
94
|
+
"""The machines pixie lists now, or ``None`` when pixie cannot
|
|
95
|
+
be reached, so the caller can keep what it had."""
|
|
96
|
+
try:
|
|
97
|
+
r = self._client.get(f"{self.url}/machines")
|
|
98
|
+
r.raise_for_status()
|
|
99
|
+
machines = r.json()["machines"]
|
|
100
|
+
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
|
101
|
+
log.warning("pixie %s: %s; keeping its things from the last sync", self.url, exc)
|
|
102
|
+
return None
|
|
103
|
+
things = []
|
|
104
|
+
for m in machines:
|
|
105
|
+
try:
|
|
106
|
+
name = validate_thing_name(str(m.get("name", "")))
|
|
107
|
+
except ValueError:
|
|
108
|
+
continue # a pixie from before machine names; nothing to call it
|
|
109
|
+
attrs: dict[str, Any] = {"mac": m["mac"], "pixie": self.url}
|
|
110
|
+
for key in ("boot_mode", "labels", "last_seen_ip"):
|
|
111
|
+
if m.get(key):
|
|
112
|
+
attrs[key] = m[key]
|
|
113
|
+
things.append(Thing(name, "machine", self.id, attrs))
|
|
114
|
+
return things
|
|
115
|
+
|
|
116
|
+
def info(self, name: str) -> dict[str, Any] | None:
|
|
117
|
+
"""pixie's description of one machine, or ``None`` if it has none."""
|
|
118
|
+
try:
|
|
119
|
+
r = self._client.get(f"{self.url}/machines/{name}/info")
|
|
120
|
+
if r.status_code == 404:
|
|
121
|
+
return None
|
|
122
|
+
r.raise_for_status()
|
|
123
|
+
info: dict[str, Any] = r.json()
|
|
124
|
+
return info
|
|
125
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
126
|
+
log.warning("pixie %s: info for %s: %s", self.url, name, exc)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def sync(
|
|
131
|
+
store: Store, config: Config, pixies: list[PixieSource], *, now: datetime | None = None
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Rebuild the things table from every source."""
|
|
134
|
+
stamp = ts(now or datetime.now(UTC))
|
|
135
|
+
previous = store.list_things()
|
|
136
|
+
merged: dict[str, Thing] = {}
|
|
137
|
+
for source in pixies:
|
|
138
|
+
fetched = source.fetch()
|
|
139
|
+
if fetched is None:
|
|
140
|
+
fetched = [t for t in previous if t.source == source.id]
|
|
141
|
+
else:
|
|
142
|
+
for t in fetched:
|
|
143
|
+
t.synced_at = stamp
|
|
144
|
+
for t in fetched:
|
|
145
|
+
merged.setdefault(t.name, t)
|
|
146
|
+
for t in config.things:
|
|
147
|
+
base = merged.get(t.name)
|
|
148
|
+
if base is not None:
|
|
149
|
+
base.attrs = {**base.attrs, **t.attrs}
|
|
150
|
+
base.kind = t.kind
|
|
151
|
+
else:
|
|
152
|
+
merged[t.name] = Thing(t.name, t.kind, t.source, dict(t.attrs), stamp)
|
|
153
|
+
store.replace_things(sorted(merged.values(), key=lambda t: t.name))
|
dibs/_sshsig.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
4
|
+
|
|
5
|
+
"""SSH public keys and ``ssh-keygen -Y`` signatures.
|
|
6
|
+
|
|
7
|
+
Signature checking is left to OpenSSH, the reference implementation of
|
|
8
|
+
the format, rather than reimplemented here. Signatures are made and
|
|
9
|
+
checked in the ``dibs`` namespace, so one made for dibs cannot be
|
|
10
|
+
passed off as, say, a git commit signature, or the other way round.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
NAMESPACE = "dibs"
|
|
20
|
+
|
|
21
|
+
_KEY_TYPES = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"ssh-ed25519",
|
|
24
|
+
"ssh-rsa",
|
|
25
|
+
"ecdsa-sha2-nistp256",
|
|
26
|
+
"ecdsa-sha2-nistp384",
|
|
27
|
+
"ecdsa-sha2-nistp521",
|
|
28
|
+
"sk-ssh-ed25519@openssh.com",
|
|
29
|
+
"sk-ecdsa-sha2-nistp256@openssh.com",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
_SIGNATURE_MAX = 8192
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_pubkey(raw: str) -> tuple[str, str]:
|
|
36
|
+
"""``(key, fingerprint)`` for one OpenSSH public key line. ``key`` is
|
|
37
|
+
the ``<type> <base64>`` pair with any comment dropped; raises
|
|
38
|
+
:class:`ValueError` on anything ``ssh-keygen`` does not accept."""
|
|
39
|
+
parts = (raw or "").strip().split()
|
|
40
|
+
if len(parts) < 2 or "\n" in raw.strip() or parts[0] not in _KEY_TYPES:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"pubkey must be one OpenSSH public key line, e.g. the contents of id_ed25519.pub"
|
|
43
|
+
)
|
|
44
|
+
key = f"{parts[0]} {parts[1]}"
|
|
45
|
+
out = subprocess.run(
|
|
46
|
+
["ssh-keygen", "-l", "-E", "sha256", "-f", "-"],
|
|
47
|
+
input=key + "\n",
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
timeout=10,
|
|
51
|
+
)
|
|
52
|
+
if out.returncode != 0:
|
|
53
|
+
raise ValueError("pubkey is not a valid OpenSSH public key")
|
|
54
|
+
return key, out.stdout.split()[1]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def verify(*, principal: str, key: str, message: str, signature: str) -> bool:
|
|
58
|
+
"""True when ``signature`` is a ``dibs``-namespace signature of
|
|
59
|
+
``message`` by ``key``. ``principal`` must already be validated: it
|
|
60
|
+
is written into the ``allowed_signers`` line."""
|
|
61
|
+
if len(signature) > _SIGNATURE_MAX or "BEGIN SSH SIGNATURE" not in signature:
|
|
62
|
+
return False
|
|
63
|
+
with tempfile.TemporaryDirectory(prefix="dibs-sshsig-") as tmp:
|
|
64
|
+
allowed = Path(tmp) / "allowed_signers"
|
|
65
|
+
allowed.write_text(f'{principal} namespaces="{NAMESPACE}" {key}\n')
|
|
66
|
+
sig = Path(tmp) / "message.sig"
|
|
67
|
+
sig.write_text(signature)
|
|
68
|
+
out = subprocess.run(
|
|
69
|
+
[
|
|
70
|
+
"ssh-keygen",
|
|
71
|
+
"-Y",
|
|
72
|
+
"verify",
|
|
73
|
+
"-f",
|
|
74
|
+
str(allowed),
|
|
75
|
+
"-I",
|
|
76
|
+
principal,
|
|
77
|
+
"-n",
|
|
78
|
+
NAMESPACE,
|
|
79
|
+
"-s",
|
|
80
|
+
str(sig),
|
|
81
|
+
],
|
|
82
|
+
input=message,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=10,
|
|
86
|
+
)
|
|
87
|
+
return out.returncode == 0
|
dibs/_store.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
4
|
+
|
|
5
|
+
"""Things and the claims on them.
|
|
6
|
+
|
|
7
|
+
A **thing** is whatever people share and claim, mostly lab machines. The
|
|
8
|
+
things table is rebuilt from the sources on every sync (see
|
|
9
|
+
:mod:`dibs._sources`); dibs never edits it otherwise. **Claims** live in
|
|
10
|
+
a table of their own, keyed by thing name, so a claim outlives a sync in
|
|
11
|
+
which its source was unreachable or briefly forgot the thing.
|
|
12
|
+
|
|
13
|
+
Every surface (``dibs``, the web page, the login hook on a machine)
|
|
14
|
+
prints a claim as the same sentence, from :func:`sentence`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import builtins
|
|
20
|
+
import contextlib
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import sqlite3
|
|
24
|
+
import threading
|
|
25
|
+
from collections.abc import Generator, Sequence
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import UTC, datetime, timedelta
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
_WRITE_LOCK = threading.Lock()
|
|
32
|
+
|
|
33
|
+
# What people type and put in configs next to the hostname, so a host
|
|
34
|
+
# label: lowercase a-z, 0-9 and -, at most 63 chars.
|
|
35
|
+
THING_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
|
36
|
+
|
|
37
|
+
PURPOSE_MAX = 128
|
|
38
|
+
ASSISTED_BY_MAX = 64
|
|
39
|
+
|
|
40
|
+
_SCHEMA = """
|
|
41
|
+
CREATE TABLE IF NOT EXISTS things (
|
|
42
|
+
name TEXT PRIMARY KEY,
|
|
43
|
+
kind TEXT NOT NULL,
|
|
44
|
+
source TEXT NOT NULL,
|
|
45
|
+
attrs TEXT NOT NULL DEFAULT '{}',
|
|
46
|
+
synced_at TEXT NOT NULL
|
|
47
|
+
);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
49
|
+
thing TEXT PRIMARY KEY,
|
|
50
|
+
holder TEXT NOT NULL,
|
|
51
|
+
assisted_by TEXT NOT NULL DEFAULT '',
|
|
52
|
+
purpose TEXT NOT NULL,
|
|
53
|
+
claimed_at TEXT NOT NULL,
|
|
54
|
+
expires_at TEXT NOT NULL DEFAULT ''
|
|
55
|
+
);
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ts(at: datetime) -> str:
|
|
60
|
+
return at.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_ts(raw: str) -> datetime:
|
|
64
|
+
return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_thing_name(raw: str) -> str:
|
|
68
|
+
name = (raw or "").strip().lower()
|
|
69
|
+
if not THING_NAME_RE.match(name):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"name {name!r} must be a hostname label: a-z / 0-9 / -, "
|
|
72
|
+
"1-63 chars, no leading or trailing -"
|
|
73
|
+
)
|
|
74
|
+
return name
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _one_line(field_name: str, raw: str, limit: int) -> str:
|
|
78
|
+
value = (raw or "").strip()
|
|
79
|
+
if not value:
|
|
80
|
+
raise ValueError(f"{field_name} must not be empty")
|
|
81
|
+
if len(value) > limit:
|
|
82
|
+
raise ValueError(f"{field_name} must be at most {limit} chars")
|
|
83
|
+
# Printed into other people's terminals by the login hook, so no
|
|
84
|
+
# control characters (an ESC would let a claim restyle the terminal).
|
|
85
|
+
if not value.isprintable():
|
|
86
|
+
raise ValueError(f"{field_name} must be a single line of printable text")
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_purpose(raw: str) -> str:
|
|
91
|
+
return _one_line("purpose", raw, PURPOSE_MAX)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_assisted_by(raw: str) -> str:
|
|
95
|
+
"""Optional, so blank passes."""
|
|
96
|
+
return _one_line("assisted_by", raw, ASSISTED_BY_MAX) if (raw or "").strip() else ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Thing:
|
|
101
|
+
name: str
|
|
102
|
+
kind: str
|
|
103
|
+
source: str
|
|
104
|
+
"""``config``, or ``pixie:<url>`` for a thing a pixie server reported."""
|
|
105
|
+
attrs: dict[str, Any] = field(default_factory=dict)
|
|
106
|
+
synced_at: str = ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Claim:
|
|
111
|
+
thing: str
|
|
112
|
+
holder: str
|
|
113
|
+
"""The dibs user who holds the claim."""
|
|
114
|
+
purpose: str
|
|
115
|
+
claimed_at: str
|
|
116
|
+
assisted_by: str = ""
|
|
117
|
+
"""The agent the holder claimed through, in ``Agent:model`` form like
|
|
118
|
+
an ``Assisted-by:`` trailer; blank for a claim made by hand."""
|
|
119
|
+
expires_at: str = ""
|
|
120
|
+
"""Blank for a claim that never expires."""
|
|
121
|
+
|
|
122
|
+
def is_live(self, now: datetime) -> bool:
|
|
123
|
+
"""A claim past its expiry counts as no claim: the thing is free
|
|
124
|
+
to take without ``force``, and nothing has to sweep it up."""
|
|
125
|
+
return not self.expires_at or parse_ts(self.expires_at) > now
|
|
126
|
+
|
|
127
|
+
def to_dict(self) -> dict[str, str]:
|
|
128
|
+
out = {
|
|
129
|
+
"holder": self.holder,
|
|
130
|
+
"purpose": self.purpose,
|
|
131
|
+
"claimed_at": self.claimed_at,
|
|
132
|
+
}
|
|
133
|
+
if self.assisted_by:
|
|
134
|
+
out["assisted_by"] = self.assisted_by
|
|
135
|
+
if self.expires_at:
|
|
136
|
+
out["expires_at"] = self.expires_at
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ClaimConflict(ValueError):
|
|
141
|
+
"""One or more things are claimed by another user."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, blockers: Sequence[Claim], now: datetime) -> None:
|
|
144
|
+
self.blockers = list(blockers)
|
|
145
|
+
self.sentences = [sentence(c.thing, c, now) for c in blockers]
|
|
146
|
+
super().__init__("; ".join(self.sentences))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def remaining(expires_at: str, now: datetime) -> str:
|
|
150
|
+
"""``never`` or the time left as ``2d 3h`` / ``1h 12m`` / ``4m``, to
|
|
151
|
+
the two largest units. Rounds up to the minute, so a claim made for
|
|
152
|
+
``2h`` reads ``2h`` rather than ``1h 59m`` a second later."""
|
|
153
|
+
if not expires_at:
|
|
154
|
+
return "never"
|
|
155
|
+
secs = max(0, int((parse_ts(expires_at) - now).total_seconds()))
|
|
156
|
+
days, rest = divmod(-(-secs // 60), 1440)
|
|
157
|
+
hours, minutes = divmod(rest, 60)
|
|
158
|
+
parts = [f"{n}{u}" for n, u in ((days, "d"), (hours, "h"), (minutes, "m")) if n]
|
|
159
|
+
return " ".join(parts[:2]) or "<1m"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def sentence(name: str, claim: Claim | None, now: datetime) -> str:
|
|
163
|
+
if claim is None or not claim.is_live(now):
|
|
164
|
+
return f"'{name}' is free"
|
|
165
|
+
who = f"'{claim.holder}'"
|
|
166
|
+
if claim.assisted_by:
|
|
167
|
+
who += f" assisted by '{claim.assisted_by}'"
|
|
168
|
+
return (
|
|
169
|
+
f"{who} has claimed '{name}' for '{claim.purpose}' "
|
|
170
|
+
f"expires in: '{remaining(claim.expires_at, now)}'"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Store:
|
|
175
|
+
def __init__(self, db_path: Path) -> None:
|
|
176
|
+
self.db_path = Path(db_path)
|
|
177
|
+
with self._conn() as conn:
|
|
178
|
+
conn.executescript(_SCHEMA)
|
|
179
|
+
|
|
180
|
+
@contextlib.contextmanager
|
|
181
|
+
def _conn(self) -> Generator[sqlite3.Connection]:
|
|
182
|
+
conn = sqlite3.connect(str(self.db_path))
|
|
183
|
+
conn.execute("PRAGMA busy_timeout = 5000")
|
|
184
|
+
conn.row_factory = sqlite3.Row
|
|
185
|
+
try:
|
|
186
|
+
yield conn
|
|
187
|
+
conn.commit()
|
|
188
|
+
finally:
|
|
189
|
+
conn.close()
|
|
190
|
+
|
|
191
|
+
# ---- things -----------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def replace_things(self, things: Sequence[Thing]) -> None:
|
|
194
|
+
"""Make ``things`` the whole list, in one transaction."""
|
|
195
|
+
with _WRITE_LOCK, self._conn() as conn:
|
|
196
|
+
conn.execute("DELETE FROM things")
|
|
197
|
+
conn.executemany(
|
|
198
|
+
"INSERT INTO things (name, kind, source, attrs, synced_at) VALUES (?, ?, ?, ?, ?)",
|
|
199
|
+
[(t.name, t.kind, t.source, json.dumps(t.attrs), t.synced_at) for t in things],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def list_things(self) -> builtins.list[Thing]:
|
|
203
|
+
with self._conn() as conn:
|
|
204
|
+
rows = conn.execute("SELECT * FROM things ORDER BY name").fetchall()
|
|
205
|
+
return [_thing(r) for r in rows]
|
|
206
|
+
|
|
207
|
+
def get_thing(self, name: str) -> Thing | None:
|
|
208
|
+
with self._conn() as conn:
|
|
209
|
+
row = conn.execute(
|
|
210
|
+
"SELECT * FROM things WHERE name = ?", ((name or "").strip().lower(),)
|
|
211
|
+
).fetchone()
|
|
212
|
+
return _thing(row) if row else None
|
|
213
|
+
|
|
214
|
+
def thing_by_mac(self, macs: Sequence[str]) -> Thing | None:
|
|
215
|
+
wanted = {m.strip().lower() for m in macs if m.strip()}
|
|
216
|
+
for thing in self.list_things():
|
|
217
|
+
if str(thing.attrs.get("mac", "")).lower() in wanted:
|
|
218
|
+
return thing
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
# ---- claims -----------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def get_claim(self, name: str) -> Claim | None:
|
|
224
|
+
with self._conn() as conn:
|
|
225
|
+
row = conn.execute("SELECT * FROM claims WHERE thing = ?", (name,)).fetchone()
|
|
226
|
+
return Claim(**dict(row)) if row else None
|
|
227
|
+
|
|
228
|
+
def list_claims(self) -> dict[str, Claim]:
|
|
229
|
+
with self._conn() as conn:
|
|
230
|
+
rows = conn.execute("SELECT * FROM claims").fetchall()
|
|
231
|
+
return {r["thing"]: Claim(**dict(r)) for r in rows}
|
|
232
|
+
|
|
233
|
+
def claim(
|
|
234
|
+
self,
|
|
235
|
+
names: Sequence[str],
|
|
236
|
+
*,
|
|
237
|
+
holder: str,
|
|
238
|
+
purpose: str,
|
|
239
|
+
assisted_by: str = "",
|
|
240
|
+
expires_in: int | None = None,
|
|
241
|
+
force: bool = False,
|
|
242
|
+
now: datetime | None = None,
|
|
243
|
+
) -> builtins.list[tuple[Claim | None, Claim]]:
|
|
244
|
+
"""Claim every thing in ``names`` for ``holder``, or none of them:
|
|
245
|
+
if any holds a live claim by another user (and ``force`` is off)
|
|
246
|
+
this raises :class:`ClaimConflict` and changes nothing.
|
|
247
|
+
Ownership is the user's: claiming again as the same holder, with
|
|
248
|
+
or without an agent, updates the purpose, expiry and
|
|
249
|
+
``assisted_by`` and keeps ``claimed_at``. ``expires_in`` is
|
|
250
|
+
seconds from now; ``None`` never expires. Returns ``(before,
|
|
251
|
+
after)`` pairs so the caller can log what a ``force`` took over.
|
|
252
|
+
Callers check that the things exist."""
|
|
253
|
+
purpose = validate_purpose(purpose)
|
|
254
|
+
assisted_by = validate_assisted_by(assisted_by)
|
|
255
|
+
if expires_in is not None and expires_in <= 0:
|
|
256
|
+
raise ValueError("expires_in must be a positive number of seconds")
|
|
257
|
+
at = now or datetime.now(UTC)
|
|
258
|
+
expires_at = ts(at + timedelta(seconds=expires_in)) if expires_in is not None else ""
|
|
259
|
+
unique = list(dict.fromkeys(names))
|
|
260
|
+
with _WRITE_LOCK, self._conn() as conn:
|
|
261
|
+
before = {n: self._claim_row(conn, n) for n in unique}
|
|
262
|
+
live = {n: c for n, c in before.items() if c is not None and c.is_live(at)}
|
|
263
|
+
blockers = [c for c in live.values() if c.holder != holder]
|
|
264
|
+
if blockers and not force:
|
|
265
|
+
raise ClaimConflict(blockers, at)
|
|
266
|
+
out: builtins.list[tuple[Claim | None, Claim]] = []
|
|
267
|
+
for n in unique:
|
|
268
|
+
prior = live.get(n)
|
|
269
|
+
since = prior.claimed_at if prior and prior.holder == holder else ts(at)
|
|
270
|
+
after = Claim(n, holder, purpose, since, assisted_by, expires_at)
|
|
271
|
+
conn.execute(
|
|
272
|
+
"INSERT OR REPLACE INTO claims "
|
|
273
|
+
"(thing, holder, assisted_by, purpose, claimed_at, expires_at) "
|
|
274
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
275
|
+
(n, holder, assisted_by, purpose, since, expires_at),
|
|
276
|
+
)
|
|
277
|
+
out.append((prior, after))
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
def release(
|
|
281
|
+
self,
|
|
282
|
+
names: Sequence[str],
|
|
283
|
+
*,
|
|
284
|
+
holder: str,
|
|
285
|
+
force: bool = False,
|
|
286
|
+
now: datetime | None = None,
|
|
287
|
+
) -> builtins.list[Claim]:
|
|
288
|
+
"""Release the claims on ``names``, all or nothing like
|
|
289
|
+
:meth:`claim`: releasing another user's live claim needs
|
|
290
|
+
``force``. Expired claims are cleared whoever holds them. Returns
|
|
291
|
+
the live claims released."""
|
|
292
|
+
at = now or datetime.now(UTC)
|
|
293
|
+
unique = list(dict.fromkeys(names))
|
|
294
|
+
with _WRITE_LOCK, self._conn() as conn:
|
|
295
|
+
before = [c for c in (self._claim_row(conn, n) for n in unique) if c is not None]
|
|
296
|
+
blockers = [c for c in before if c.is_live(at) and c.holder != holder]
|
|
297
|
+
if blockers and not force:
|
|
298
|
+
raise ClaimConflict(blockers, at)
|
|
299
|
+
conn.executemany("DELETE FROM claims WHERE thing = ?", [(c.thing,) for c in before])
|
|
300
|
+
return [c for c in before if c.is_live(at)]
|
|
301
|
+
|
|
302
|
+
def claimed_by(
|
|
303
|
+
self, holder: str, assisted_by: str = "", *, now: datetime | None = None
|
|
304
|
+
) -> builtins.list[Claim]:
|
|
305
|
+
"""Live claims held by ``holder`` through exactly ``assisted_by``
|
|
306
|
+
(blank: claimed by hand), so an agent's ``release --mine`` leaves
|
|
307
|
+
its human's own claims alone."""
|
|
308
|
+
at = now or datetime.now(UTC)
|
|
309
|
+
with self._conn() as conn:
|
|
310
|
+
rows = conn.execute(
|
|
311
|
+
"SELECT * FROM claims WHERE holder = ? AND assisted_by = ? ORDER BY thing",
|
|
312
|
+
(holder, assisted_by.strip()),
|
|
313
|
+
).fetchall()
|
|
314
|
+
return [c for c in (Claim(**dict(r)) for r in rows) if c.is_live(at)]
|
|
315
|
+
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _claim_row(conn: sqlite3.Connection, name: str) -> Claim | None:
|
|
318
|
+
row = conn.execute("SELECT * FROM claims WHERE thing = ?", (name,)).fetchone()
|
|
319
|
+
return Claim(**dict(row)) if row else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _thing(r: sqlite3.Row) -> Thing:
|
|
323
|
+
try:
|
|
324
|
+
attrs = json.loads(r["attrs"] or "{}")
|
|
325
|
+
except ValueError:
|
|
326
|
+
attrs = {}
|
|
327
|
+
return Thing(r["name"], r["kind"], r["source"], attrs, r["synced_at"])
|