pixie-lab-dibs 0.1.1__tar.gz → 0.2.1__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.1}/.pre-commit-config.yaml +4 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/CHANGELOG.md +21 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/PKG-INFO +45 -8
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/README.md +44 -7
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/src/dibs/__init__.py +1 -1
- pixie_lab_dibs-0.2.1/src/dibs/_skill.py +26 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/src/dibs/_sources.py +10 -2
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/src/dibs/_store.py +69 -9
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/src/dibs/app.py +65 -16
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/src/dibs/cli.py +24 -0
- pixie_lab_dibs-0.2.1/src/dibs/skill/SKILL.md +65 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/tests/test_app.py +38 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/tests/test_cli.py +34 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/tests/test_sources.py +8 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/tests/test_store.py +72 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/.github/workflows/format.yml +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/.github/workflows/publish.yml +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/.github/workflows/test.yml +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/.gitignore +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/LICENSE +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/LICENSES/BSD-3-Clause.txt +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/Makefile +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/REUSE.toml +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/pyproject.toml +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/scripts/service-smoke.sh +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/src/dibs/_sshsig.py +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/src/dibs/_users.py +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/tests/__init__.py +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/tests/conftest.py +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/tests/test_users.py +0 -0
- {pixie_lab_dibs-0.1.1 → pixie_lab_dibs-0.2.1}/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,27 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.2.1] - 2026-10-27
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `GET /agents` explains the server to whoever is pointed at it: setup
|
|
18
|
+
with this server's URL filled in, what is claimed right now, and the
|
|
19
|
+
agent skill's rules. The web page links to it.
|
|
20
|
+
|
|
21
|
+
## [0.2.0] - 2026-10-27
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Parts: a thing can have parts named `<thing>.<part>`, claimable on
|
|
26
|
+
their own. Claiming the thing covers its parts, so it conflicts with
|
|
27
|
+
another user's claim on any part, and a part conflicts with another
|
|
28
|
+
user's claim on the thing. Forcing the thing takes over its parts.
|
|
29
|
+
- `dibs skill install` puts a Claude Code skill in
|
|
30
|
+
`~/.claude/skills/dibs/` that teaches an agent to claim a lab machine
|
|
31
|
+
before using it, to claim the whole machine for anything through the
|
|
32
|
+
BMC, and to release when done. `dibs skill print` shows it.
|
|
33
|
+
|
|
13
34
|
## [0.1.1] - 2026-10-27
|
|
14
35
|
|
|
15
36
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pixie-lab-dibs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.1
|
|
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,44 @@ 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
|
+
Every dibs server explains itself at `/agents`: how to set up for this
|
|
171
|
+
very server, with its URL filled in, what is claimed right now, and the
|
|
172
|
+
rules below. So the one thing to tell a person or an agent about a lab
|
|
173
|
+
is "before touching its machines, read `http://dibs:8090/agents`", in a
|
|
174
|
+
prompt, a project's `AGENTS.md`, or a message.
|
|
175
|
+
|
|
176
|
+
For Claude Code, install the skill that teaches an agent all of this,
|
|
177
|
+
and it applies whenever the agent is about to use a lab machine:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
dibs skill install
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`dibs skill print` shows it, to adapt for another agent. An agent claims
|
|
184
|
+
as its human, and names itself the way an `Assisted-by:` trailer does:
|
|
151
185
|
|
|
152
186
|
```
|
|
153
187
|
export DIBS_ASSISTED_BY="Claude Code:claude-opus-5-5"
|
|
@@ -157,11 +191,13 @@ The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' has clai
|
|
|
157
191
|
|
|
158
192
|
If you are an agent reading this: before you run anything on a lab
|
|
159
193
|
machine, run `dibs list` and claim the machine with
|
|
160
|
-
`dibs claim <machine> --for <purpose>`.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
194
|
+
`dibs claim <machine> --for <purpose>`. Power-cycling, the console,
|
|
195
|
+
reflashing or anything else through the BMC needs the whole machine,
|
|
196
|
+
never just a part. Never use a machine someone else has claimed, and
|
|
197
|
+
never pass `--force`; ask your human instead. Release what you claimed
|
|
198
|
+
with `dibs release --mine` when the work is done, and also when it fails
|
|
199
|
+
or is abandoned. That releases only what you claimed, not your human's
|
|
200
|
+
own claims.
|
|
165
201
|
|
|
166
202
|
### Automation-runners
|
|
167
203
|
|
|
@@ -227,6 +263,7 @@ no login; claim and release take `Authorization: Bearer <token>`.
|
|
|
227
263
|
| `POST /auth/register` | `{"user": "...", "pubkey": "ssh-ed25519 ..."}` |
|
|
228
264
|
| `POST /auth/challenge` | `{"user": "..."}` gives a nonce and the message to sign |
|
|
229
265
|
| `POST /auth/login` | `{"user", "nonce", "signature"}` gives a token |
|
|
266
|
+
| `GET /agents` | how to use this server, for a person or an agent, as text |
|
|
230
267
|
| `GET /login.sh` | the login hook |
|
|
231
268
|
|
|
232
269
|
A claim or release blocked by someone else's claim answers 409 with
|
|
@@ -122,10 +122,44 @@ 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
|
+
Every dibs server explains itself at `/agents`: how to set up for this
|
|
149
|
+
very server, with its URL filled in, what is claimed right now, and the
|
|
150
|
+
rules below. So the one thing to tell a person or an agent about a lab
|
|
151
|
+
is "before touching its machines, read `http://dibs:8090/agents`", in a
|
|
152
|
+
prompt, a project's `AGENTS.md`, or a message.
|
|
153
|
+
|
|
154
|
+
For Claude Code, install the skill that teaches an agent all of this,
|
|
155
|
+
and it applies whenever the agent is about to use a lab machine:
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
dibs skill install
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`dibs skill print` shows it, to adapt for another agent. An agent claims
|
|
162
|
+
as its human, and names itself the way an `Assisted-by:` trailer does:
|
|
129
163
|
|
|
130
164
|
```
|
|
131
165
|
export DIBS_ASSISTED_BY="Claude Code:claude-opus-5-5"
|
|
@@ -135,11 +169,13 @@ The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' has clai
|
|
|
135
169
|
|
|
136
170
|
If you are an agent reading this: before you run anything on a lab
|
|
137
171
|
machine, run `dibs list` and claim the machine with
|
|
138
|
-
`dibs claim <machine> --for <purpose>`.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
172
|
+
`dibs claim <machine> --for <purpose>`. Power-cycling, the console,
|
|
173
|
+
reflashing or anything else through the BMC needs the whole machine,
|
|
174
|
+
never just a part. Never use a machine someone else has claimed, and
|
|
175
|
+
never pass `--force`; ask your human instead. Release what you claimed
|
|
176
|
+
with `dibs release --mine` when the work is done, and also when it fails
|
|
177
|
+
or is abandoned. That releases only what you claimed, not your human's
|
|
178
|
+
own claims.
|
|
143
179
|
|
|
144
180
|
### Automation-runners
|
|
145
181
|
|
|
@@ -205,6 +241,7 @@ no login; claim and release take `Authorization: Bearer <token>`.
|
|
|
205
241
|
| `POST /auth/register` | `{"user": "...", "pubkey": "ssh-ed25519 ..."}` |
|
|
206
242
|
| `POST /auth/challenge` | `{"user": "..."}` gives a nonce and the message to sign |
|
|
207
243
|
| `POST /auth/login` | `{"user", "nonce", "signature"}` gives a token |
|
|
244
|
+
| `GET /agents` | how to use this server, for a person or an agent, as text |
|
|
208
245
|
| `GET /login.sh` | the login hook |
|
|
209
246
|
|
|
210
247
|
A claim or release blocked by someone else's claim answers 409 with
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
4
|
+
|
|
5
|
+
"""The agent skill, shared by ``dibs skill`` and the server's ``/agents``
|
|
6
|
+
page, so both say exactly the same thing."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
SKILL_PATH = Path(__file__).parent / "skill" / "SKILL.md"
|
|
14
|
+
|
|
15
|
+
# The YAML frontmatter Claude Code reads, then the REUSE comment.
|
|
16
|
+
_PREAMBLE = re.compile(r"\A---\n.*?\n---\n\s*<!--.*?-->\s*", re.DOTALL)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def skill_text() -> str:
|
|
20
|
+
"""The skill as Claude Code installs it."""
|
|
21
|
+
return SKILL_PATH.read_text()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def skill_body() -> str:
|
|
25
|
+
"""The protocol alone, for readers that are not Claude Code."""
|
|
26
|
+
return _PREAMBLE.sub("", skill_text(), count=1)
|
|
@@ -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()
|
|
@@ -30,8 +30,9 @@ from fastapi.responses import HTMLResponse, PlainTextResponse, Response
|
|
|
30
30
|
from pydantic import BaseModel, Field
|
|
31
31
|
|
|
32
32
|
import dibs
|
|
33
|
+
from dibs._skill import skill_body
|
|
33
34
|
from dibs._sources import Config, PixieSource, load_config, sync
|
|
34
|
-
from dibs._store import Claim, ClaimConflict, Store, Thing, sentence
|
|
35
|
+
from dibs._store import Claim, ClaimConflict, Store, Thing, covering, describe, sentence
|
|
35
36
|
from dibs._users import DuplicateUser, LoginFailed, NotApproved, UsersStore
|
|
36
37
|
|
|
37
38
|
log = logging.getLogger("dibs")
|
|
@@ -80,19 +81,28 @@ def current_user(request: Request) -> str:
|
|
|
80
81
|
return str(user)
|
|
81
82
|
|
|
82
83
|
|
|
83
|
-
def _view(
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
def _view(
|
|
85
|
+
name: str, thing: Thing | None, claims: dict[str, Claim], now: datetime
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
"""One thing as the API shows it. ``claim`` is a claim on this very
|
|
88
|
+
name; ``covered_by`` lists every live claim that says who is on it,
|
|
89
|
+
which for a part includes its thing's claim and for a thing its
|
|
90
|
+
parts'. A claim on a thing no source lists any more still shows,
|
|
91
|
+
with ``source`` ``gone``, so it can be released."""
|
|
86
92
|
out: dict[str, Any] = {
|
|
87
93
|
"name": name,
|
|
88
94
|
"kind": thing.kind if thing else "unknown",
|
|
89
95
|
"source": thing.source if thing else "gone",
|
|
90
|
-
"sentence":
|
|
96
|
+
"sentence": describe(name, claims, now),
|
|
91
97
|
}
|
|
92
98
|
if thing and thing.attrs:
|
|
93
99
|
out["attrs"] = thing.attrs
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
own = claims.get(name)
|
|
101
|
+
if own is not None and own.is_live(now):
|
|
102
|
+
out["claim"] = own.to_dict()
|
|
103
|
+
found = covering(name, claims, now)
|
|
104
|
+
if found:
|
|
105
|
+
out["covered_by"] = [{"thing": c.thing, **c.to_dict()} for c in found]
|
|
96
106
|
return out
|
|
97
107
|
|
|
98
108
|
|
|
@@ -153,16 +163,17 @@ def create_app(
|
|
|
153
163
|
things = {t.name: t for t in store.list_things()}
|
|
154
164
|
claims = store.list_claims()
|
|
155
165
|
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
|
|
166
|
+
return {"things": [_view(n, things.get(n), claims, now) for n in names]}
|
|
157
167
|
|
|
158
168
|
@app.get("/things/{name}")
|
|
159
169
|
def get_thing(name: str) -> dict[str, Any]:
|
|
160
170
|
now = datetime.now(UTC)
|
|
171
|
+
name = name.strip().lower()
|
|
161
172
|
thing = store.get_thing(name)
|
|
162
|
-
|
|
163
|
-
if thing is None and
|
|
173
|
+
claims = store.list_claims()
|
|
174
|
+
if thing is None and name not in claims:
|
|
164
175
|
raise HTTPException(status_code=404, detail=f"no such thing: {name}")
|
|
165
|
-
out = _view(name
|
|
176
|
+
out = _view(name, thing, claims, now)
|
|
166
177
|
if thing is not None and thing.source in app.state.pixies:
|
|
167
178
|
info = app.state.pixies[thing.source].info(thing.name)
|
|
168
179
|
if info is not None:
|
|
@@ -174,7 +185,7 @@ def create_app(
|
|
|
174
185
|
name = name.strip().lower()
|
|
175
186
|
if store.get_thing(name) is None:
|
|
176
187
|
raise HTTPException(status_code=404, detail=f"no such thing: {name}")
|
|
177
|
-
return PlainTextResponse(
|
|
188
|
+
return PlainTextResponse(describe(name, store.list_claims(), datetime.now(UTC)) + "\n")
|
|
178
189
|
|
|
179
190
|
@app.get("/claim", response_class=PlainTextResponse)
|
|
180
191
|
def claim_for_host(mac: str = "", host: str = "") -> PlainTextResponse:
|
|
@@ -183,7 +194,7 @@ def create_app(
|
|
|
183
194
|
thing = store.thing_by_mac(mac.split(",")) or (store.get_thing(host) if host else None)
|
|
184
195
|
if thing is None:
|
|
185
196
|
raise HTTPException(status_code=404, detail="not a thing dibs knows")
|
|
186
|
-
text =
|
|
197
|
+
text = describe(thing.name, store.list_claims(), datetime.now(UTC))
|
|
187
198
|
return PlainTextResponse(text + "\n")
|
|
188
199
|
|
|
189
200
|
@app.post("/claims")
|
|
@@ -209,7 +220,9 @@ def create_app(
|
|
|
209
220
|
log.warning("%s took %s from %s", user, after.thing, before.holder)
|
|
210
221
|
log.info("%s", sentence(after.thing, after, now))
|
|
211
222
|
things = {t.name: t for t in store.list_things()}
|
|
212
|
-
|
|
223
|
+
claims = store.list_claims()
|
|
224
|
+
claimed = list(dict.fromkeys(a.thing for _, a in pairs))
|
|
225
|
+
return {"things": [_view(n, things.get(n), claims, now) for n in claimed]}
|
|
213
226
|
|
|
214
227
|
@app.post("/claims/release")
|
|
215
228
|
def release(body: ReleaseBody, user: str = Depends(current_user)) -> dict[str, Any]:
|
|
@@ -225,9 +238,10 @@ def create_app(
|
|
|
225
238
|
except ClaimConflict as exc:
|
|
226
239
|
raise _conflict(exc) from exc
|
|
227
240
|
things = {t.name: t for t in store.list_things()}
|
|
241
|
+
claims = store.list_claims()
|
|
228
242
|
for c in released:
|
|
229
243
|
log.info("%s released %s (held by %s)", user, c.thing, c.holder)
|
|
230
|
-
return {"things": [_view(c.thing, things.get(c.thing),
|
|
244
|
+
return {"things": [_view(c.thing, things.get(c.thing), claims, now) for c in released]}
|
|
231
245
|
|
|
232
246
|
@app.post("/auth/register", status_code=202)
|
|
233
247
|
def register(body: RegisterBody) -> dict[str, str]:
|
|
@@ -284,6 +298,16 @@ def create_app(
|
|
|
284
298
|
login. It points back at whichever dibs URL fetched it."""
|
|
285
299
|
return PlainTextResponse(LOGIN_HOOK.format(base=str(request.base_url)))
|
|
286
300
|
|
|
301
|
+
@app.get("/agents", response_class=PlainTextResponse)
|
|
302
|
+
def agents(request: Request) -> PlainTextResponse:
|
|
303
|
+
"""How to use dibs on this lab, for a person or an agent pointed at
|
|
304
|
+
it: the setup with this server's URL filled in, what is claimed
|
|
305
|
+
right now, then the protocol from the agent skill."""
|
|
306
|
+
base = str(request.base_url).rstrip("/")
|
|
307
|
+
now_lines = "\n".join(f"- {t['sentence']}" for t in list_things()["things"])
|
|
308
|
+
page = AGENTS_PAGE.format(base=base, now=now_lines or "- nothing to claim yet")
|
|
309
|
+
return PlainTextResponse(page + "\n" + skill_body(), media_type="text/plain; charset=utf-8")
|
|
310
|
+
|
|
287
311
|
@app.get("/", response_class=HTMLResponse)
|
|
288
312
|
def index() -> HTMLResponse:
|
|
289
313
|
rows = list_things()["things"]
|
|
@@ -324,6 +348,30 @@ case $- in *i*)
|
|
|
324
348
|
esac
|
|
325
349
|
"""
|
|
326
350
|
|
|
351
|
+
AGENTS_PAGE = """\
|
|
352
|
+
# dibs on this lab
|
|
353
|
+
|
|
354
|
+
The machines on this lab are shared by people, ai-agents and
|
|
355
|
+
automation-runners, and claimed with dibs at {base}. Read this before you
|
|
356
|
+
touch any of them.
|
|
357
|
+
|
|
358
|
+
## Set up, once per machine you work from
|
|
359
|
+
|
|
360
|
+
```
|
|
361
|
+
uv tool install pixie-lab-dibs
|
|
362
|
+
export DIBS_URL={base}
|
|
363
|
+
dibs register <your name> # then ask the lab's admin to approve you
|
|
364
|
+
dibs login
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
An agent uses its human's login and needs none of its own. For Claude
|
|
368
|
+
Code, `dibs skill install` makes the rules below load by themselves.
|
|
369
|
+
|
|
370
|
+
## Right now
|
|
371
|
+
|
|
372
|
+
{now}
|
|
373
|
+
"""
|
|
374
|
+
|
|
327
375
|
PAGE = """<!doctype html>
|
|
328
376
|
<html lang="en"><head><meta charset="utf-8"><title>dibs</title>
|
|
329
377
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -334,7 +382,8 @@ td, th {{ text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #ddd;
|
|
|
334
382
|
</style></head><body>
|
|
335
383
|
<h1>dibs</h1>
|
|
336
384
|
<p>Who is using which thing. Claim one with
|
|
337
|
-
<code>dibs claim <name> --for <purpose></code
|
|
385
|
+
<code>dibs claim <name> --for <purpose></code>.
|
|
386
|
+
New here, or an agent? Read <a href="/agents">/agents</a>.</p>
|
|
338
387
|
<table><thead><tr><th>Name</th><th>Kind</th><th>Claim</th></tr></thead>
|
|
339
388
|
<tbody>
|
|
340
389
|
{rows}
|
|
@@ -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
|
+
|
|
554
|
+
def cmd_skill(args: argparse.Namespace) -> None:
|
|
555
|
+
"""Install the Claude Code skill that teaches an agent to claim
|
|
556
|
+
machines before using them, or print it for another agent's setup."""
|
|
557
|
+
from dibs._skill import skill_text
|
|
558
|
+
|
|
559
|
+
text = skill_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,41 @@ 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'")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_agents_page_is_set_up_for_this_server(client: TestClient, tokens) -> None:
|
|
182
|
+
from dibs._skill import skill_body
|
|
183
|
+
|
|
184
|
+
_claim(client, tokens, "warp")
|
|
185
|
+
r = client.get("/agents")
|
|
186
|
+
assert r.headers["content-type"].startswith("text/plain")
|
|
187
|
+
page = r.text
|
|
188
|
+
assert "export DIBS_URL=http://testserver\n" in page
|
|
189
|
+
assert "- 'alice' has claimed 'warp' for 'xnvme' expires in: 'never'" in page
|
|
190
|
+
assert page.endswith(skill_body())
|
|
191
|
+
# The skill's Claude Code frontmatter and licence comment are not for this reader.
|
|
192
|
+
assert "name: dibs" not in page and "SPDX" not in page
|
|
193
|
+
assert '<a href="/agents">' in client.get("/").text
|
|
@@ -259,3 +259,37 @@ 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()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_skill_body_drops_only_the_preamble() -> None:
|
|
291
|
+
from dibs._skill import skill_body, skill_text
|
|
292
|
+
|
|
293
|
+
body = skill_body()
|
|
294
|
+
assert body.startswith("# Claim lab machines with dibs\n")
|
|
295
|
+
assert skill_text().endswith(body)
|
|
@@ -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
|