pixie-lab-dibs 0.1.1__tar.gz → 0.2.0__tar.gz
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.
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/.pre-commit-config.yaml +4 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/CHANGELOG.md +13 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/PKG-INFO +38 -8
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/README.md +37 -7
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/__init__.py +1 -1
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/_sources.py +10 -2
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/_store.py +69 -9
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/app.py +28 -15
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/cli.py +24 -0
- pixie_lab_dibs-0.2.0/src/dibs/skill/SKILL.md +65 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/test_app.py +23 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/test_cli.py +26 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/test_sources.py +8 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/test_store.py +72 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/.github/workflows/format.yml +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/.github/workflows/publish.yml +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/.github/workflows/test.yml +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/.gitignore +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/LICENSE +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/LICENSES/BSD-3-Clause.txt +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/Makefile +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/REUSE.toml +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/pyproject.toml +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/scripts/service-smoke.sh +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/_sshsig.py +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/src/dibs/_users.py +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/__init__.py +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/conftest.py +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/tests/test_users.py +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.0}/uv.lock +0 -0
|
@@ -34,6 +34,10 @@ repos:
|
|
|
34
34
|
# fail CI there.
|
|
35
35
|
additional_dependencies:
|
|
36
36
|
- mdformat-gfm==1.0.0
|
|
37
|
+
# Without this, mdformat rewrites the agent skill's YAML frontmatter
|
|
38
|
+
# into a horizontal rule and a heading, and Claude Code stops
|
|
39
|
+
# finding the skill.
|
|
40
|
+
- mdformat-frontmatter==2.1.2
|
|
37
41
|
|
|
38
42
|
- repo: https://github.com/fsfe/reuse-tool
|
|
39
43
|
rev: v6.2.0
|
|
@@ -10,6 +10,19 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.2.0] - 2026-10-27
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Parts: a thing can have parts named `<thing>.<part>`, claimable on
|
|
18
|
+
their own. Claiming the thing covers its parts, so it conflicts with
|
|
19
|
+
another user's claim on any part, and a part conflicts with another
|
|
20
|
+
user's claim on the thing. Forcing the thing takes over its parts.
|
|
21
|
+
- `dibs skill install` puts a Claude Code skill in
|
|
22
|
+
`~/.claude/skills/dibs/` that teaches an agent to claim a lab machine
|
|
23
|
+
before using it, to claim the whole machine for anything through the
|
|
24
|
+
BMC, and to release when done. `dibs skill print` shows it.
|
|
25
|
+
|
|
13
26
|
## [0.1.1] - 2026-10-27
|
|
14
27
|
|
|
15
28
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pixie-lab-dibs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Call dibs on shared lab machines: see who is using what, claim it, release it
|
|
5
5
|
Project-URL: Homepage, https://github.com/safl/pixie-lab-dibs
|
|
6
6
|
Author-email: "Simon A. F. Lund" <os@safl.dk>
|
|
@@ -144,10 +144,38 @@ those rather than parse the text.
|
|
|
144
144
|
The exit status is 0 when done, 1 when refused because someone else
|
|
145
145
|
holds a thing, and 2 for anything else.
|
|
146
146
|
|
|
147
|
+
### Parts
|
|
148
|
+
|
|
149
|
+
A thing can have parts that people use independently, named
|
|
150
|
+
`<thing>.<part>` and listed in the config like any other thing:
|
|
151
|
+
|
|
152
|
+
```toml
|
|
153
|
+
[[thing]]
|
|
154
|
+
name = "epyc-02.gpu1"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Claiming `epyc-02` takes the whole machine, parts included, so it is
|
|
158
|
+
refused while someone else holds a part. Claiming `epyc-02.gpu1` leaves
|
|
159
|
+
the other parts to others, and is refused while someone else holds the
|
|
160
|
+
whole machine. A part listed without its thing still makes the thing
|
|
161
|
+
claimable.
|
|
162
|
+
|
|
163
|
+
Only make something a part if using it cannot disturb the rest of the
|
|
164
|
+
machine. Anything that can, such as the BMC, power, the console or
|
|
165
|
+
reflashing, needs a claim on the whole machine, so it is an attribute of
|
|
166
|
+
the thing (`bmc = "10.20.30.82"`) rather than a part.
|
|
167
|
+
|
|
147
168
|
### AI-agents
|
|
148
169
|
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
For Claude Code, install the skill that teaches an agent all of this,
|
|
171
|
+
and it applies whenever the agent is about to use a lab machine:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
dibs skill install
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`dibs skill print` shows it, to adapt for another agent. An agent claims
|
|
178
|
+
as its human, and names itself the way an `Assisted-by:` trailer does:
|
|
151
179
|
|
|
152
180
|
```
|
|
153
181
|
export DIBS_ASSISTED_BY="Claude Code:claude-opus-5-5"
|
|
@@ -157,11 +185,13 @@ The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' has clai
|
|
|
157
185
|
|
|
158
186
|
If you are an agent reading this: before you run anything on a lab
|
|
159
187
|
machine, run `dibs list` and claim the machine with
|
|
160
|
-
`dibs claim <machine> --for <purpose>`.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
188
|
+
`dibs claim <machine> --for <purpose>`. Power-cycling, the console,
|
|
189
|
+
reflashing or anything else through the BMC needs the whole machine,
|
|
190
|
+
never just a part. Never use a machine someone else has claimed, and
|
|
191
|
+
never pass `--force`; ask your human instead. Release what you claimed
|
|
192
|
+
with `dibs release --mine` when the work is done, and also when it fails
|
|
193
|
+
or is abandoned. That releases only what you claimed, not your human's
|
|
194
|
+
own claims.
|
|
165
195
|
|
|
166
196
|
### Automation-runners
|
|
167
197
|
|
|
@@ -122,10 +122,38 @@ those rather than parse the text.
|
|
|
122
122
|
The exit status is 0 when done, 1 when refused because someone else
|
|
123
123
|
holds a thing, and 2 for anything else.
|
|
124
124
|
|
|
125
|
+
### Parts
|
|
126
|
+
|
|
127
|
+
A thing can have parts that people use independently, named
|
|
128
|
+
`<thing>.<part>` and listed in the config like any other thing:
|
|
129
|
+
|
|
130
|
+
```toml
|
|
131
|
+
[[thing]]
|
|
132
|
+
name = "epyc-02.gpu1"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Claiming `epyc-02` takes the whole machine, parts included, so it is
|
|
136
|
+
refused while someone else holds a part. Claiming `epyc-02.gpu1` leaves
|
|
137
|
+
the other parts to others, and is refused while someone else holds the
|
|
138
|
+
whole machine. A part listed without its thing still makes the thing
|
|
139
|
+
claimable.
|
|
140
|
+
|
|
141
|
+
Only make something a part if using it cannot disturb the rest of the
|
|
142
|
+
machine. Anything that can, such as the BMC, power, the console or
|
|
143
|
+
reflashing, needs a claim on the whole machine, so it is an attribute of
|
|
144
|
+
the thing (`bmc = "10.20.30.82"`) rather than a part.
|
|
145
|
+
|
|
125
146
|
### AI-agents
|
|
126
147
|
|
|
127
|
-
|
|
128
|
-
|
|
148
|
+
For Claude Code, install the skill that teaches an agent all of this,
|
|
149
|
+
and it applies whenever the agent is about to use a lab machine:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
dibs skill install
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`dibs skill print` shows it, to adapt for another agent. An agent claims
|
|
156
|
+
as its human, and names itself the way an `Assisted-by:` trailer does:
|
|
129
157
|
|
|
130
158
|
```
|
|
131
159
|
export DIBS_ASSISTED_BY="Claude Code:claude-opus-5-5"
|
|
@@ -135,11 +163,13 @@ The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' has clai
|
|
|
135
163
|
|
|
136
164
|
If you are an agent reading this: before you run anything on a lab
|
|
137
165
|
machine, run `dibs list` and claim the machine with
|
|
138
|
-
`dibs claim <machine> --for <purpose>`.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
166
|
+
`dibs claim <machine> --for <purpose>`. Power-cycling, the console,
|
|
167
|
+
reflashing or anything else through the BMC needs the whole machine,
|
|
168
|
+
never just a part. Never use a machine someone else has claimed, and
|
|
169
|
+
never pass `--force`; ask your human instead. Release what you claimed
|
|
170
|
+
with `dibs release --mine` when the work is done, and also when it fails
|
|
171
|
+
or is abandoned. That releases only what you claimed, not your human's
|
|
172
|
+
own claims.
|
|
143
173
|
|
|
144
174
|
### Automation-runners
|
|
145
175
|
|
|
@@ -18,7 +18,9 @@ hand and names the pixie servers to poll::
|
|
|
18
18
|
mac = "aa:bb:cc:dd:ee:ff" # any other key is kept as an attribute
|
|
19
19
|
|
|
20
20
|
A ``[[thing]]`` with the name of a pixie machine adds its attributes to
|
|
21
|
-
that machine rather than making a second thing.
|
|
21
|
+
that machine rather than making a second thing. A name with a dot is a
|
|
22
|
+
part of the thing before the dot, ``epyc-02.bmc`` of ``epyc-02``, claimable
|
|
23
|
+
on its own or along with the whole thing.
|
|
22
24
|
"""
|
|
23
25
|
|
|
24
26
|
from __future__ import annotations
|
|
@@ -32,7 +34,7 @@ from typing import Any
|
|
|
32
34
|
|
|
33
35
|
import httpx
|
|
34
36
|
|
|
35
|
-
from dibs._store import Store, Thing, ts, validate_thing_name
|
|
37
|
+
from dibs._store import Store, Thing, owner, ts, validate_thing_name
|
|
36
38
|
|
|
37
39
|
log = logging.getLogger("dibs")
|
|
38
40
|
|
|
@@ -150,4 +152,10 @@ def sync(
|
|
|
150
152
|
base.kind = t.kind
|
|
151
153
|
else:
|
|
152
154
|
merged[t.name] = Thing(t.name, t.kind, t.source, dict(t.attrs), stamp)
|
|
155
|
+
# A part listed without its thing still makes the thing claimable as a
|
|
156
|
+
# whole, so "epyc-02.bmc" alone gives you "epyc-02" too.
|
|
157
|
+
for t in list(merged.values()):
|
|
158
|
+
whole = owner(t.name)
|
|
159
|
+
if whole not in merged:
|
|
160
|
+
merged[whole] = Thing(whole, t.kind, t.source, {}, stamp)
|
|
153
161
|
store.replace_things(sorted(merged.values(), key=lambda t: t.name))
|
|
@@ -10,6 +10,13 @@ things table is rebuilt from the sources on every sync (see
|
|
|
10
10
|
a table of their own, keyed by thing name, so a claim outlives a sync in
|
|
11
11
|
which its source was unreachable or briefly forgot the thing.
|
|
12
12
|
|
|
13
|
+
A thing may have **parts**, named ``<thing>.<part>``, such as
|
|
14
|
+
``epyc-02.ssh`` and ``epyc-02.bmc``. Claiming the thing covers all of its
|
|
15
|
+
parts; claiming a part leaves the others free for someone else. So a
|
|
16
|
+
claim on a thing conflicts with another user's claim on any of its parts,
|
|
17
|
+
and a claim on a part conflicts with another user's claim on that part
|
|
18
|
+
or on the thing.
|
|
19
|
+
|
|
13
20
|
Every surface (``dibs``, the web page, the login hook on a machine)
|
|
14
21
|
prints a claim as the same sentence, from :func:`sentence`.
|
|
15
22
|
"""
|
|
@@ -31,8 +38,11 @@ from typing import Any
|
|
|
31
38
|
_WRITE_LOCK = threading.Lock()
|
|
32
39
|
|
|
33
40
|
# What people type and put in configs next to the hostname, so a host
|
|
34
|
-
# label
|
|
35
|
-
|
|
41
|
+
# label (lowercase a-z, 0-9 and -, at most 63 chars), optionally followed
|
|
42
|
+
# by one ".<part>" label. A hostname label has no dot, so the dot is free
|
|
43
|
+
# to mean "part of".
|
|
44
|
+
_LABEL = r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
|
|
45
|
+
THING_NAME_RE = re.compile(rf"^{_LABEL}(\.{_LABEL})?$")
|
|
36
46
|
|
|
37
47
|
PURPOSE_MAX = 128
|
|
38
48
|
ASSISTED_BY_MAX = 64
|
|
@@ -68,12 +78,23 @@ def validate_thing_name(raw: str) -> str:
|
|
|
68
78
|
name = (raw or "").strip().lower()
|
|
69
79
|
if not THING_NAME_RE.match(name):
|
|
70
80
|
raise ValueError(
|
|
71
|
-
f"name {name!r} must be a hostname label
|
|
72
|
-
"
|
|
81
|
+
f"name {name!r} must be a hostname label (a-z / 0-9 / -, 1-63 chars, "
|
|
82
|
+
"no leading or trailing -), optionally with one .part, e.g. epyc-02.bmc"
|
|
73
83
|
)
|
|
74
84
|
return name
|
|
75
85
|
|
|
76
86
|
|
|
87
|
+
def owner(name: str) -> str:
|
|
88
|
+
"""The thing a part belongs to; a thing is its own owner."""
|
|
89
|
+
return name.split(".", 1)[0]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def overlaps(a: str, b: str) -> bool:
|
|
93
|
+
"""Whether claims on ``a`` and ``b`` cover any of the same thing: the
|
|
94
|
+
same name, or a thing and one of its parts."""
|
|
95
|
+
return a == b or a == owner(b) or b == owner(a)
|
|
96
|
+
|
|
97
|
+
|
|
77
98
|
def _one_line(field_name: str, raw: str, limit: int) -> str:
|
|
78
99
|
value = (raw or "").strip()
|
|
79
100
|
if not value:
|
|
@@ -160,17 +181,42 @@ def remaining(expires_at: str, now: datetime) -> str:
|
|
|
160
181
|
|
|
161
182
|
|
|
162
183
|
def sentence(name: str, claim: Claim | None, now: datetime) -> str:
|
|
184
|
+
"""One claim as it reads everywhere. The claim names the thing it is
|
|
185
|
+
on, which for a part covered by its thing's claim is not ``name``."""
|
|
163
186
|
if claim is None or not claim.is_live(now):
|
|
164
187
|
return f"'{name}' is free"
|
|
165
188
|
who = f"'{claim.holder}'"
|
|
166
189
|
if claim.assisted_by:
|
|
167
190
|
who += f" assisted by '{claim.assisted_by}'"
|
|
168
191
|
return (
|
|
169
|
-
f"{who} has claimed '{
|
|
192
|
+
f"{who} has claimed '{claim.thing}' for '{claim.purpose}' "
|
|
170
193
|
f"expires in: '{remaining(claim.expires_at, now)}'"
|
|
171
194
|
)
|
|
172
195
|
|
|
173
196
|
|
|
197
|
+
def covering(name: str, claims: dict[str, Claim], now: datetime) -> builtins.list[Claim]:
|
|
198
|
+
"""The live claims that say who is on ``name``: its own claim, else
|
|
199
|
+
its thing's claim for a part, else its parts' claims for a thing."""
|
|
200
|
+
own = claims.get(name)
|
|
201
|
+
if own is not None and own.is_live(now):
|
|
202
|
+
return [own]
|
|
203
|
+
if "." in name:
|
|
204
|
+
whole = claims.get(owner(name))
|
|
205
|
+
return [whole] if whole is not None and whole.is_live(now) else []
|
|
206
|
+
return sorted(
|
|
207
|
+
(c for c in claims.values() if owner(c.thing) == name and c.is_live(now)),
|
|
208
|
+
key=lambda c: c.thing,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def describe(name: str, claims: dict[str, Claim], now: datetime) -> str:
|
|
213
|
+
"""The sentence for ``name``, counting claims on its thing or parts."""
|
|
214
|
+
found = covering(name, claims, now)
|
|
215
|
+
if not found:
|
|
216
|
+
return f"'{name}' is free"
|
|
217
|
+
return "; ".join(sentence(c.thing, c, now) for c in found)
|
|
218
|
+
|
|
219
|
+
|
|
174
220
|
class Store:
|
|
175
221
|
def __init__(self, db_path: Path) -> None:
|
|
176
222
|
self.db_path = Path(db_path)
|
|
@@ -248,8 +294,10 @@ class Store:
|
|
|
248
294
|
or without an agent, updates the purpose, expiry and
|
|
249
295
|
``assisted_by`` and keeps ``claimed_at``. ``expires_in`` is
|
|
250
296
|
seconds from now; ``None`` never expires. Returns ``(before,
|
|
251
|
-
after)`` pairs so the caller can log what a ``force`` took over
|
|
252
|
-
|
|
297
|
+
after)`` pairs so the caller can log what a ``force`` took over;
|
|
298
|
+
a forced claim on a thing also takes over other users' claims on
|
|
299
|
+
its parts, and those appear as pairs too. Callers check that the
|
|
300
|
+
things exist."""
|
|
253
301
|
purpose = validate_purpose(purpose)
|
|
254
302
|
assisted_by = validate_assisted_by(assisted_by)
|
|
255
303
|
if expires_in is not None and expires_in <= 0:
|
|
@@ -260,14 +308,16 @@ class Store:
|
|
|
260
308
|
with _WRITE_LOCK, self._conn() as conn:
|
|
261
309
|
before = {n: self._claim_row(conn, n) for n in unique}
|
|
262
310
|
live = {n: c for n, c in before.items() if c is not None and c.is_live(at)}
|
|
263
|
-
|
|
311
|
+
others = [c for c in self._live_rows(conn, at) if c.holder != holder]
|
|
312
|
+
blockers = [c for c in others if any(overlaps(c.thing, n) for n in unique)]
|
|
264
313
|
if blockers and not force:
|
|
265
314
|
raise ClaimConflict(blockers, at)
|
|
266
315
|
out: builtins.list[tuple[Claim | None, Claim]] = []
|
|
316
|
+
afters: dict[str, Claim] = {}
|
|
267
317
|
for n in unique:
|
|
268
318
|
prior = live.get(n)
|
|
269
319
|
since = prior.claimed_at if prior and prior.holder == holder else ts(at)
|
|
270
|
-
after = Claim(n, holder, purpose, since, assisted_by, expires_at)
|
|
320
|
+
after = afters[n] = Claim(n, holder, purpose, since, assisted_by, expires_at)
|
|
271
321
|
conn.execute(
|
|
272
322
|
"INSERT OR REPLACE INTO claims "
|
|
273
323
|
"(thing, holder, assisted_by, purpose, claimed_at, expires_at) "
|
|
@@ -275,6 +325,11 @@ class Store:
|
|
|
275
325
|
(n, holder, assisted_by, purpose, since, expires_at),
|
|
276
326
|
)
|
|
277
327
|
out.append((prior, after))
|
|
328
|
+
for b in blockers:
|
|
329
|
+
if b.thing not in afters:
|
|
330
|
+
conn.execute("DELETE FROM claims WHERE thing = ?", (b.thing,))
|
|
331
|
+
covering_name = next(n for n in unique if overlaps(b.thing, n))
|
|
332
|
+
out.append((b, afters[covering_name]))
|
|
278
333
|
return out
|
|
279
334
|
|
|
280
335
|
def release(
|
|
@@ -313,6 +368,11 @@ class Store:
|
|
|
313
368
|
).fetchall()
|
|
314
369
|
return [c for c in (Claim(**dict(r)) for r in rows) if c.is_live(at)]
|
|
315
370
|
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _live_rows(conn: sqlite3.Connection, at: datetime) -> builtins.list[Claim]:
|
|
373
|
+
rows = conn.execute("SELECT * FROM claims").fetchall()
|
|
374
|
+
return [c for c in (Claim(**dict(r)) for r in rows) if c.is_live(at)]
|
|
375
|
+
|
|
316
376
|
@staticmethod
|
|
317
377
|
def _claim_row(conn: sqlite3.Connection, name: str) -> Claim | None:
|
|
318
378
|
row = conn.execute("SELECT * FROM claims WHERE thing = ?", (name,)).fetchone()
|
|
@@ -31,7 +31,7 @@ from pydantic import BaseModel, Field
|
|
|
31
31
|
|
|
32
32
|
import dibs
|
|
33
33
|
from dibs._sources import Config, PixieSource, load_config, sync
|
|
34
|
-
from dibs._store import Claim, ClaimConflict, Store, Thing, sentence
|
|
34
|
+
from dibs._store import Claim, ClaimConflict, Store, Thing, covering, describe, sentence
|
|
35
35
|
from dibs._users import DuplicateUser, LoginFailed, NotApproved, UsersStore
|
|
36
36
|
|
|
37
37
|
log = logging.getLogger("dibs")
|
|
@@ -80,19 +80,28 @@ def current_user(request: Request) -> str:
|
|
|
80
80
|
return str(user)
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
def _view(
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
def _view(
|
|
84
|
+
name: str, thing: Thing | None, claims: dict[str, Claim], now: datetime
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
"""One thing as the API shows it. ``claim`` is a claim on this very
|
|
87
|
+
name; ``covered_by`` lists every live claim that says who is on it,
|
|
88
|
+
which for a part includes its thing's claim and for a thing its
|
|
89
|
+
parts'. A claim on a thing no source lists any more still shows,
|
|
90
|
+
with ``source`` ``gone``, so it can be released."""
|
|
86
91
|
out: dict[str, Any] = {
|
|
87
92
|
"name": name,
|
|
88
93
|
"kind": thing.kind if thing else "unknown",
|
|
89
94
|
"source": thing.source if thing else "gone",
|
|
90
|
-
"sentence":
|
|
95
|
+
"sentence": describe(name, claims, now),
|
|
91
96
|
}
|
|
92
97
|
if thing and thing.attrs:
|
|
93
98
|
out["attrs"] = thing.attrs
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
own = claims.get(name)
|
|
100
|
+
if own is not None and own.is_live(now):
|
|
101
|
+
out["claim"] = own.to_dict()
|
|
102
|
+
found = covering(name, claims, now)
|
|
103
|
+
if found:
|
|
104
|
+
out["covered_by"] = [{"thing": c.thing, **c.to_dict()} for c in found]
|
|
96
105
|
return out
|
|
97
106
|
|
|
98
107
|
|
|
@@ -153,16 +162,17 @@ def create_app(
|
|
|
153
162
|
things = {t.name: t for t in store.list_things()}
|
|
154
163
|
claims = store.list_claims()
|
|
155
164
|
names = sorted(set(things) | {n for n, c in claims.items() if c.is_live(now)})
|
|
156
|
-
return {"things": [_view(n, things.get(n), claims
|
|
165
|
+
return {"things": [_view(n, things.get(n), claims, now) for n in names]}
|
|
157
166
|
|
|
158
167
|
@app.get("/things/{name}")
|
|
159
168
|
def get_thing(name: str) -> dict[str, Any]:
|
|
160
169
|
now = datetime.now(UTC)
|
|
170
|
+
name = name.strip().lower()
|
|
161
171
|
thing = store.get_thing(name)
|
|
162
|
-
|
|
163
|
-
if thing is None and
|
|
172
|
+
claims = store.list_claims()
|
|
173
|
+
if thing is None and name not in claims:
|
|
164
174
|
raise HTTPException(status_code=404, detail=f"no such thing: {name}")
|
|
165
|
-
out = _view(name
|
|
175
|
+
out = _view(name, thing, claims, now)
|
|
166
176
|
if thing is not None and thing.source in app.state.pixies:
|
|
167
177
|
info = app.state.pixies[thing.source].info(thing.name)
|
|
168
178
|
if info is not None:
|
|
@@ -174,7 +184,7 @@ def create_app(
|
|
|
174
184
|
name = name.strip().lower()
|
|
175
185
|
if store.get_thing(name) is None:
|
|
176
186
|
raise HTTPException(status_code=404, detail=f"no such thing: {name}")
|
|
177
|
-
return PlainTextResponse(
|
|
187
|
+
return PlainTextResponse(describe(name, store.list_claims(), datetime.now(UTC)) + "\n")
|
|
178
188
|
|
|
179
189
|
@app.get("/claim", response_class=PlainTextResponse)
|
|
180
190
|
def claim_for_host(mac: str = "", host: str = "") -> PlainTextResponse:
|
|
@@ -183,7 +193,7 @@ def create_app(
|
|
|
183
193
|
thing = store.thing_by_mac(mac.split(",")) or (store.get_thing(host) if host else None)
|
|
184
194
|
if thing is None:
|
|
185
195
|
raise HTTPException(status_code=404, detail="not a thing dibs knows")
|
|
186
|
-
text =
|
|
196
|
+
text = describe(thing.name, store.list_claims(), datetime.now(UTC))
|
|
187
197
|
return PlainTextResponse(text + "\n")
|
|
188
198
|
|
|
189
199
|
@app.post("/claims")
|
|
@@ -209,7 +219,9 @@ def create_app(
|
|
|
209
219
|
log.warning("%s took %s from %s", user, after.thing, before.holder)
|
|
210
220
|
log.info("%s", sentence(after.thing, after, now))
|
|
211
221
|
things = {t.name: t for t in store.list_things()}
|
|
212
|
-
|
|
222
|
+
claims = store.list_claims()
|
|
223
|
+
claimed = list(dict.fromkeys(a.thing for _, a in pairs))
|
|
224
|
+
return {"things": [_view(n, things.get(n), claims, now) for n in claimed]}
|
|
213
225
|
|
|
214
226
|
@app.post("/claims/release")
|
|
215
227
|
def release(body: ReleaseBody, user: str = Depends(current_user)) -> dict[str, Any]:
|
|
@@ -225,9 +237,10 @@ def create_app(
|
|
|
225
237
|
except ClaimConflict as exc:
|
|
226
238
|
raise _conflict(exc) from exc
|
|
227
239
|
things = {t.name: t for t in store.list_things()}
|
|
240
|
+
claims = store.list_claims()
|
|
228
241
|
for c in released:
|
|
229
242
|
log.info("%s released %s (held by %s)", user, c.thing, c.holder)
|
|
230
|
-
return {"things": [_view(c.thing, things.get(c.thing),
|
|
243
|
+
return {"things": [_view(c.thing, things.get(c.thing), claims, now) for c in released]}
|
|
231
244
|
|
|
232
245
|
@app.post("/auth/register", status_code=202)
|
|
233
246
|
def register(body: RegisterBody) -> dict[str, str]:
|
|
@@ -548,6 +548,25 @@ def cmd_admin_revoke(args: argparse.Namespace) -> None:
|
|
|
548
548
|
print(f"Revoked {count} token(s) of '{args.name}'; they must log in again.")
|
|
549
549
|
|
|
550
550
|
|
|
551
|
+
# ---- agent skill -----------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
SKILL = Path(__file__).parent / "skill" / "SKILL.md"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def cmd_skill(args: argparse.Namespace) -> None:
|
|
557
|
+
"""Install the Claude Code skill that teaches an agent to claim
|
|
558
|
+
machines before using them, or print it for another agent's setup."""
|
|
559
|
+
text = SKILL.read_text()
|
|
560
|
+
if args.action == "print":
|
|
561
|
+
sys.stdout.write(text)
|
|
562
|
+
return
|
|
563
|
+
target = Path(args.dir).expanduser() if args.dir else Path.home() / ".claude" / "skills"
|
|
564
|
+
path = target / "dibs" / "SKILL.md"
|
|
565
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
566
|
+
path.write_text(text)
|
|
567
|
+
print(f"Installed {path}. Claude Code picks it up in new sessions.")
|
|
568
|
+
|
|
569
|
+
|
|
551
570
|
# ---- shell completion ------------------------------------------------------
|
|
552
571
|
|
|
553
572
|
# Commands whose positional arguments are thing names, which the
|
|
@@ -730,6 +749,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
730
749
|
user_mode(p)
|
|
731
750
|
p.set_defaults(func=func)
|
|
732
751
|
|
|
752
|
+
p = sub.add_parser("skill", help="install the agent skill that says how to use dibs")
|
|
753
|
+
p.add_argument("action", choices=["install", "print"])
|
|
754
|
+
p.add_argument("--dir", help="skills directory (default: ~/.claude/skills)")
|
|
755
|
+
p.set_defaults(func=cmd_skill)
|
|
756
|
+
|
|
733
757
|
p = sub.add_parser("completion", help="print a shell completion script")
|
|
734
758
|
p.add_argument("shell", choices=["bash"])
|
|
735
759
|
p.set_defaults(func=cmd_completion)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dibs
|
|
3
|
+
description: Claim a shared lab machine with dibs before using it, and release it after. Use before you SSH into, run tests on, flash, reboot, power-cycle, or open the BMC or console of any lab machine (e.g. epyc-01, epyc-02, ryzen-01), including through cijoe, ipmitool or Redfish.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<!--
|
|
7
|
+
SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
|
|
8
|
+
|
|
9
|
+
SPDX-License-Identifier: BSD-3-Clause
|
|
10
|
+
-->
|
|
11
|
+
|
|
12
|
+
# Claim lab machines with dibs
|
|
13
|
+
|
|
14
|
+
Lab machines are shared with people, other agents and CI. dibs says who
|
|
15
|
+
is on which. A claim is a note, not a lock: nothing stops you from
|
|
16
|
+
logging in to a machine someone else holds, so it is on you not to.
|
|
17
|
+
|
|
18
|
+
## Before you touch a machine
|
|
19
|
+
|
|
20
|
+
1. Look: `dibs list`. Use `dibs list --json` to read it, not the text.
|
|
21
|
+
|
|
22
|
+
1. Claim it, naming yourself as an agent and saying what for:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
DIBS_ASSISTED_BY="Claude Code:<your model id>" dibs claim <machine> --for "<what you are doing>"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Set `DIBS_ASSISTED_BY` on every dibs command, inline, so each one
|
|
29
|
+
agrees on who you are. It uses your human's login; you need no login
|
|
30
|
+
of your own.
|
|
31
|
+
|
|
32
|
+
1. Exit status 1 means someone else holds it. Do not use the machine and
|
|
33
|
+
do not pass `--force`. Tell your human who holds it (the error says)
|
|
34
|
+
and ask what to do.
|
|
35
|
+
|
|
36
|
+
Anything that goes through the BMC or affects the whole box needs the
|
|
37
|
+
whole machine: power-cycling, the serial or graphical console, booting
|
|
38
|
+
or reflashing it, firmware, and kernel or driver changes. Claim
|
|
39
|
+
`<machine>` for those.
|
|
40
|
+
|
|
41
|
+
Some machines list parts in `dibs list`, named `<machine>.<part>`. Claim
|
|
42
|
+
a part only when your work stays inside it and cannot disturb the rest
|
|
43
|
+
of the machine; claiming the machine takes all of its parts. Claim
|
|
44
|
+
several things in one command; it is all or nothing.
|
|
45
|
+
|
|
46
|
+
## When you are done
|
|
47
|
+
|
|
48
|
+
Release what you claimed, and also when the work fails, is abandoned, or
|
|
49
|
+
waits on your human for long:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
DIBS_ASSISTED_BY="Claude Code:<your model id>" dibs release --mine
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This releases only what you claimed, not your human's own claims.
|
|
56
|
+
|
|
57
|
+
## Never
|
|
58
|
+
|
|
59
|
+
- Use a machine claimed by someone else, or one you have not claimed.
|
|
60
|
+
- Pass `--force`, or release a claim you did not make.
|
|
61
|
+
- Power-cycle, reflash, reboot or use the BMC of a machine when you hold
|
|
62
|
+
only a part of it.
|
|
63
|
+
|
|
64
|
+
If `dibs` is not installed or `DIBS_URL` is not set, stop and tell your
|
|
65
|
+
human rather than going ahead without a claim.
|
|
@@ -153,3 +153,26 @@ def test_hook_sends_macs_and_hostname_once(hook: Path, tmp_path: Path) -> None:
|
|
|
153
153
|
assert r.stdout == ""
|
|
154
154
|
sent = calls.read_text().splitlines()
|
|
155
155
|
assert len(sent) == 1 and "mac=" in sent[0] and "host=" in sent[0]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_parts_over_the_api(client: TestClient, tokens, tmp_path: Path) -> None:
|
|
159
|
+
"""A part and its thing, through claim, list, the text route and the
|
|
160
|
+
login hook's lookup."""
|
|
161
|
+
config = client.app.state.config # type: ignore[attr-defined]
|
|
162
|
+
from dibs._store import Thing
|
|
163
|
+
|
|
164
|
+
config.things.append(Thing("ryzen-01.bmc", "machine", "config", {"host": "10.0.0.61"}))
|
|
165
|
+
client.app.state.sync() # type: ignore[attr-defined]
|
|
166
|
+
assert _claim(client, tokens, "ryzen-01.bmc", who="bob").status_code == 200
|
|
167
|
+
r = _claim(client, tokens, "ryzen-01")
|
|
168
|
+
assert r.status_code == 409
|
|
169
|
+
assert r.json()["detail"]["claimed"] == [
|
|
170
|
+
"'bob' has claimed 'ryzen-01.bmc' for 'xnvme' expires in: 'never'"
|
|
171
|
+
]
|
|
172
|
+
things = {t["name"]: t for t in client.get("/things").json()["things"]}
|
|
173
|
+
assert things["ryzen-01"]["sentence"].startswith("'bob' has claimed 'ryzen-01.bmc'")
|
|
174
|
+
assert "claim" not in things["ryzen-01"]
|
|
175
|
+
assert things["ryzen-01"]["covered_by"][0]["thing"] == "ryzen-01.bmc"
|
|
176
|
+
# The login hook on ryzen-01 (by MAC) sees the part's claim.
|
|
177
|
+
hook = client.get("/claim", params={"mac": "aa:bb:cc:dd:ee:60"}).text
|
|
178
|
+
assert hook.startswith("'bob' has claimed 'ryzen-01.bmc'")
|
|
@@ -259,3 +259,29 @@ def test_bash_completion(tmp_path: Path, capsys) -> None:
|
|
|
259
259
|
assert _complete(script, "dibs claim --e", tmp_path) == ["--expires"]
|
|
260
260
|
assert _complete(script, "dibs claim w", tmp_path) == ["warp"]
|
|
261
261
|
assert "--names" not in _complete(script, "dibs list --", tmp_path)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_skill_install_and_print(tmp_path: Path, capsys) -> None:
|
|
265
|
+
assert cli.main(["skill", "install", "--dir", str(tmp_path)]) == 0
|
|
266
|
+
installed = (tmp_path / "dibs" / "SKILL.md").read_text()
|
|
267
|
+
assert installed.startswith("---\nname: dibs\n")
|
|
268
|
+
assert "dibs release --mine" in installed and "--force" in installed
|
|
269
|
+
capsys.readouterr()
|
|
270
|
+
assert cli.main(["skill", "print"]) == 0
|
|
271
|
+
assert capsys.readouterr().out == installed
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_skill_ships_in_the_wheel(tmp_path: Path) -> None:
|
|
275
|
+
"""The skill is data, not code, so check the built wheel carries it."""
|
|
276
|
+
import zipfile
|
|
277
|
+
|
|
278
|
+
if shutil.which("uv") is None:
|
|
279
|
+
pytest.skip("needs uv")
|
|
280
|
+
subprocess.run(
|
|
281
|
+
["uv", "build", "--wheel", "--out-dir", str(tmp_path)],
|
|
282
|
+
cwd=Path(__file__).parent.parent,
|
|
283
|
+
check=True,
|
|
284
|
+
capture_output=True,
|
|
285
|
+
)
|
|
286
|
+
wheel = next(tmp_path.glob("*.whl"))
|
|
287
|
+
assert "dibs/skill/SKILL.md" in zipfile.ZipFile(wheel).namelist()
|
|
@@ -72,3 +72,11 @@ def test_a_machine_pixie_forgets_disappears(tmp_path: Path, pixie: FakePixie) ->
|
|
|
72
72
|
pixie.machines = [m for m in pixie.machines if m.get("name") != "warp"]
|
|
73
73
|
sync(store, load_config(tmp_path / "dibs.toml"), [pixie.source()])
|
|
74
74
|
assert "warp" not in {t.name for t in store.list_things()}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_a_part_alone_makes_its_thing_claimable(tmp_path: Path) -> None:
|
|
78
|
+
path = tmp_path / "dibs.toml"
|
|
79
|
+
path.write_text('[[thing]]\nname = "box.bmc"\nhost = "10.0.0.2"\n')
|
|
80
|
+
store = Store(tmp_path / "dibs.db")
|
|
81
|
+
sync(store, load_config(path), [])
|
|
82
|
+
assert [t.name for t in store.list_things()] == ["box", "box.bmc"]
|
|
@@ -113,3 +113,75 @@ def test_claimed_by_is_scoped_to_assisted_by(store: Store) -> None:
|
|
|
113
113
|
def test_claim_rejects_bad_text(store: Store, purpose: str, assisted_by: str) -> None:
|
|
114
114
|
with pytest.raises(ValueError):
|
|
115
115
|
store.claim(["a"], holder="alice", purpose=purpose, assisted_by=assisted_by, now=T0)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---- parts ----------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.parametrize("name", ["epyc-02", "epyc-02.bmc", "a.b"])
|
|
122
|
+
def test_part_names_are_accepted(name: str) -> None:
|
|
123
|
+
from dibs._store import validate_thing_name
|
|
124
|
+
|
|
125
|
+
assert validate_thing_name(name) == name
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.parametrize("name", ["epyc-02.", ".bmc", "a.b.c", "a..b", "a.-b"])
|
|
129
|
+
def test_bad_part_names_are_refused(name: str) -> None:
|
|
130
|
+
from dibs._store import validate_thing_name
|
|
131
|
+
|
|
132
|
+
with pytest.raises(ValueError):
|
|
133
|
+
validate_thing_name(name)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_two_users_may_hold_different_parts(store: Store) -> None:
|
|
137
|
+
store.claim(["box.ssh"], holder="alice", purpose="x", now=T0)
|
|
138
|
+
store.claim(["box.bmc"], holder="bob", purpose="y", now=T0)
|
|
139
|
+
assert {n: c.holder for n, c in store.list_claims().items()} == {
|
|
140
|
+
"box.ssh": "alice",
|
|
141
|
+
"box.bmc": "bob",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_the_whole_thing_conflicts_with_another_users_part(store: Store) -> None:
|
|
146
|
+
store.claim(["box.bmc"], holder="bob", purpose="y", now=T0)
|
|
147
|
+
with pytest.raises(ClaimConflict) as exc:
|
|
148
|
+
store.claim(["box"], holder="alice", purpose="x", now=T0)
|
|
149
|
+
assert exc.value.sentences == ["'bob' has claimed 'box.bmc' for 'y' expires in: 'never'"]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_a_part_conflicts_with_another_users_whole_thing(store: Store) -> None:
|
|
153
|
+
store.claim(["box"], holder="bob", purpose="y", now=T0)
|
|
154
|
+
with pytest.raises(ClaimConflict):
|
|
155
|
+
store.claim(["box.ssh"], holder="alice", purpose="x", now=T0)
|
|
156
|
+
# The same user holding the whole thing may also claim a part of it.
|
|
157
|
+
store.claim(["box.ssh"], holder="bob", purpose="z", now=T0)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_parts_of_other_things_do_not_conflict(store: Store) -> None:
|
|
161
|
+
store.claim(["box.bmc"], holder="bob", purpose="y", now=T0)
|
|
162
|
+
store.claim(["boxer", "boxer.bmc"], holder="alice", purpose="x", now=T0)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_forcing_the_whole_thing_takes_over_its_parts(store: Store) -> None:
|
|
166
|
+
store.claim(["box.bmc"], holder="bob", purpose="y", now=T0)
|
|
167
|
+
pairs = store.claim(["box"], holder="alice", purpose="x", force=True, now=T0)
|
|
168
|
+
taken = [(b.thing, b.holder, a.thing) for b, a in pairs if b is not None]
|
|
169
|
+
assert taken == [("box.bmc", "bob", "box")]
|
|
170
|
+
assert set(store.list_claims()) == {"box"}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_describe_reads_through_parts_and_wholes(store: Store) -> None:
|
|
174
|
+
from dibs._store import describe
|
|
175
|
+
|
|
176
|
+
store.claim(["box.bmc"], holder="bob", purpose="power", now=T0)
|
|
177
|
+
claims = store.list_claims()
|
|
178
|
+
assert (
|
|
179
|
+
describe("box", claims, T0) == "'bob' has claimed 'box.bmc' for 'power' expires in: 'never'"
|
|
180
|
+
)
|
|
181
|
+
assert describe("box.ssh", claims, T0) == "'box.ssh' is free"
|
|
182
|
+
store.release(["box.bmc"], holder="bob", now=T0)
|
|
183
|
+
store.claim(["box"], holder="alice", purpose="all", now=T0)
|
|
184
|
+
claims = store.list_claims()
|
|
185
|
+
assert (
|
|
186
|
+
describe("box.ssh", claims, T0) == "'alice' has claimed 'box' for 'all' expires in: 'never'"
|
|
187
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|