pixie-lab-dibs 0.1.0__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.
Files changed (31) hide show
  1. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/.github/workflows/test.yml +21 -0
  2. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/.pre-commit-config.yaml +4 -0
  3. pixie_lab_dibs-0.2.0/CHANGELOG.md +49 -0
  4. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/Makefile +4 -1
  5. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/PKG-INFO +41 -10
  6. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/README.md +40 -9
  7. pixie_lab_dibs-0.2.0/scripts/service-smoke.sh +42 -0
  8. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/__init__.py +1 -1
  9. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/_sources.py +10 -2
  10. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/_store.py +69 -9
  11. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/app.py +28 -15
  12. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/cli.py +55 -4
  13. pixie_lab_dibs-0.2.0/src/dibs/skill/SKILL.md +65 -0
  14. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/test_app.py +23 -0
  15. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/test_cli.py +52 -2
  16. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/test_sources.py +8 -0
  17. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/test_store.py +72 -0
  18. pixie_lab_dibs-0.1.0/CHANGELOG.md +0 -23
  19. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/.github/workflows/format.yml +0 -0
  20. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/.github/workflows/publish.yml +0 -0
  21. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/.gitignore +0 -0
  22. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/LICENSE +0 -0
  23. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/LICENSES/BSD-3-Clause.txt +0 -0
  24. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/REUSE.toml +0 -0
  25. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/pyproject.toml +0 -0
  26. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/_sshsig.py +0 -0
  27. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/_users.py +0 -0
  28. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/__init__.py +0 -0
  29. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/conftest.py +0 -0
  30. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/test_users.py +0 -0
  31. {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/uv.lock +0 -0
@@ -40,3 +40,24 @@ jobs:
40
40
 
41
41
  - name: make build
42
42
  run: make build
43
+
44
+ service:
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - uses: actions/checkout@v7
48
+
49
+ - uses: astral-sh/setup-uv@v7.2.1
50
+
51
+ # The pixie server dibs first ran on resolves users from files only,
52
+ # without nss-systemd. Match it, so a unit that relies on systemd's
53
+ # dynamic users fails here rather than there.
54
+ - name: Resolve users from /etc/passwd only
55
+ run: |
56
+ sudo sed -i 's/^passwd:.*/passwd: files/' /etc/nsswitch.conf
57
+ grep '^passwd' /etc/nsswitch.conf
58
+
59
+ - name: make build
60
+ run: make build
61
+
62
+ - name: make service-smoke
63
+ run: make service-smoke
@@ -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
@@ -0,0 +1,49 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
3
+
4
+ SPDX-License-Identifier: BSD-3-Clause
5
+ -->
6
+
7
+ # Changelog
8
+
9
+ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
+
11
+ ## [Unreleased]
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
+
26
+ ## [0.1.1] - 2026-10-27
27
+
28
+ ### Fixed
29
+
30
+ - `dibs server install` over a running service now restarts it, so
31
+ installing again after an upgrade runs the new version. It used to
32
+ write the new unit and leave the old process running.
33
+ - The system service refused every key at `dibs register` on hosts that
34
+ resolve users without nss-systemd, plain Debian among them: under
35
+ `DynamicUser=`, `ssh-keygen` could not look its own user up. `dibs server install` now creates a `dibs` system user and runs the service
36
+ as it. Run `sudo dibs server install` again to update an installed
37
+ service.
38
+
39
+ ## [0.1.0] - 2026-10-27
40
+
41
+ ### Added
42
+
43
+ - The first version: things to claim, fed from a config file and from
44
+ pixie servers; claims that are all or nothing, never expire unless
45
+ asked to, and say when an agent made them; users who register an SSH
46
+ public key, are approved by the admin, and log in by signing a
47
+ one-time challenge; a login hook that shows a machine's claim; and
48
+ one `dibs` command for the server, its systemd service, the admin, and
49
+ everyone claiming.
@@ -9,7 +9,7 @@ UV ?= uv
9
9
  PRECOMMIT ?= pre-commit
10
10
 
11
11
  .DEFAULT_GOAL := help
12
- .PHONY: help format lint typecheck test build check
12
+ .PHONY: help format lint typecheck test build check service-smoke
13
13
 
14
14
  help: ## Show this help
15
15
  @grep -hE '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) \
@@ -35,3 +35,6 @@ build: ## Build the sdist and wheel, and check every command the wheel installs
35
35
  done
36
36
 
37
37
  check: lint typecheck test ## Everything but format
38
+
39
+ service-smoke: ## Install the built wheel as the real service and claim against it (sudo, systemd)
40
+ ./scripts/service-smoke.sh
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pixie-lab-dibs
3
- Version: 0.1.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>
@@ -81,8 +81,9 @@ sudo dibs server install
81
81
 
82
82
  The command writes a systemd unit for the `dibs` you ran, enables it
83
83
  and starts it. `dibs server start`, `stop`, `restart`, `status` and
84
- `logs` manage it from then on. The service runs as a throwaway system
85
- user that owns only `/var/lib/dibs`. Without root, `dibs server install --user` installs a per-user service instead. For a quick try,
84
+ `logs` manage it from then on. The service runs as a `dibs` system
85
+ user, which the install creates, with write access only to
86
+ `/var/lib/dibs`. Without root, `dibs server install --user` installs a per-user service instead. For a quick try,
86
87
  `dibs serve` runs the server in the foreground.
87
88
 
88
89
  The system service can only run a `dibs` installed outside your home
@@ -143,10 +144,38 @@ those rather than parse the text.
143
144
  The exit status is 0 when done, 1 when refused because someone else
144
145
  holds a thing, and 2 for anything else.
145
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
+
146
168
  ### AI-agents
147
169
 
148
- Point your agent at this section. An agent claims as its human, and
149
- names itself the way an `Assisted-by:` trailer does:
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:
150
179
 
151
180
  ```
152
181
  export DIBS_ASSISTED_BY="Claude Code:claude-opus-5-5"
@@ -156,11 +185,13 @@ The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' has clai
156
185
 
157
186
  If you are an agent reading this: before you run anything on a lab
158
187
  machine, run `dibs list` and claim the machine with
159
- `dibs claim <machine> --for <purpose>`. Never use a machine someone else
160
- has claimed, and never pass `--force`; ask your human instead. Release
161
- what you claimed with `dibs release --mine` when the work is done, and
162
- also when it fails or is abandoned. That releases only what you claimed,
163
- not your human's own claims.
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.
164
195
 
165
196
  ### Automation-runners
166
197
 
@@ -59,8 +59,9 @@ sudo dibs server install
59
59
 
60
60
  The command writes a systemd unit for the `dibs` you ran, enables it
61
61
  and starts it. `dibs server start`, `stop`, `restart`, `status` and
62
- `logs` manage it from then on. The service runs as a throwaway system
63
- user that owns only `/var/lib/dibs`. Without root, `dibs server install --user` installs a per-user service instead. For a quick try,
62
+ `logs` manage it from then on. The service runs as a `dibs` system
63
+ user, which the install creates, with write access only to
64
+ `/var/lib/dibs`. Without root, `dibs server install --user` installs a per-user service instead. For a quick try,
64
65
  `dibs serve` runs the server in the foreground.
65
66
 
66
67
  The system service can only run a `dibs` installed outside your home
@@ -121,10 +122,38 @@ those rather than parse the text.
121
122
  The exit status is 0 when done, 1 when refused because someone else
122
123
  holds a thing, and 2 for anything else.
123
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
+
124
146
  ### AI-agents
125
147
 
126
- Point your agent at this section. An agent claims as its human, and
127
- names itself the way an `Assisted-by:` trailer does:
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:
128
157
 
129
158
  ```
130
159
  export DIBS_ASSISTED_BY="Claude Code:claude-opus-5-5"
@@ -134,11 +163,13 @@ The claim then reads `'alice' assisted by 'Claude Code:claude-opus-5-5' has clai
134
163
 
135
164
  If you are an agent reading this: before you run anything on a lab
136
165
  machine, run `dibs list` and claim the machine with
137
- `dibs claim <machine> --for <purpose>`. Never use a machine someone else
138
- has claimed, and never pass `--force`; ask your human instead. Release
139
- what you claimed with `dibs release --mine` when the work is done, and
140
- also when it fails or is abandoned. That releases only what you claimed,
141
- not your human's own claims.
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.
142
173
 
143
174
  ### Automation-runners
144
175
 
@@ -0,0 +1,42 @@
1
+ #!/bin/sh
2
+ # SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
3
+ #
4
+ # SPDX-License-Identifier: BSD-3-Clause
5
+ #
6
+ # Install the built wheel as the real systemd service, then register, log
7
+ # in and claim against it. This is the path the unit tests stub out, and
8
+ # where dibs once broke: under systemd, ssh-keygen must be able to look its
9
+ # own user up. Needs root through sudo and a running systemd; CI's runner
10
+ # has both. Run from the repository root after "make build".
11
+ set -eu
12
+
13
+ wheel=$(ls dist/*.whl)
14
+ tmp=$(mktemp -d)
15
+ trap 'sudo dibs server uninstall >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT
16
+
17
+ sudo env "PATH=$PATH" UV_TOOL_DIR=/opt/uv-tools UV_TOOL_BIN_DIR=/usr/local/bin \
18
+ uv tool install --python /usr/bin/python3 "$wheel"
19
+
20
+ sudo mkdir -p /etc/dibs
21
+ printf '[[thing]]\nname = "box"\nmac = "aa:bb:cc:dd:ee:ff"\n' | sudo tee /etc/dibs/dibs.toml >/dev/null
22
+ sudo /usr/local/bin/dibs server install --port 8090
23
+
24
+ export DIBS_URL=http://127.0.0.1:8090 XDG_CONFIG_HOME="$tmp/config"
25
+ for _ in $(seq 1 30); do curl -fsS "$DIBS_URL/healthz" >/dev/null 2>&1 && break; sleep 1; done
26
+
27
+ ssh-keygen -q -t ed25519 -N '' -f "$tmp/key"
28
+ dibs register ci -i "$tmp/key.pub"
29
+ sudo dibs admin approve ci
30
+ dibs login
31
+ dibs claim box --for smoke
32
+ dibs list | grep -qx "'ci' has claimed 'box' for 'smoke' expires in: 'never'"
33
+ dibs release --mine
34
+
35
+ # Installing again, as an upgrade does, must replace the running process.
36
+ before=$(systemctl show -p MainPID --value dibs.service)
37
+ sudo /usr/local/bin/dibs server install --port 8090
38
+ after=$(systemctl show -p MainPID --value dibs.service)
39
+ [ "$before" != "$after" ] || { echo "install did not restart the running service" >&2; exit 1; }
40
+ for _ in $(seq 1 30); do curl -fsS "$DIBS_URL/healthz" >/dev/null 2>&1 && break; sleep 1; done
41
+ dibs list | grep -qx "'box' is free"
42
+ echo "service smoke test passed"
@@ -12,4 +12,4 @@ everyone who is on a machine, and it never stops anyone from logging
12
12
  in.
13
13
  """
14
14
 
15
- __version__ = "0.1.0"
15
+ __version__ = "0.2.0"
@@ -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: 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])?$")
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: a-z / 0-9 / -, "
72
- "1-63 chars, no leading or trailing -"
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 '{name}' for '{claim.purpose}' "
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
- Callers check that the things exist."""
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
- blockers = [c for c in live.values() if c.holder != holder]
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(name: str, thing: Thing | None, claim: Claim | None, now: datetime) -> dict[str, Any]:
84
- """One thing as the API shows it. A claim on a thing no source lists
85
- any more still shows, with ``source`` ``gone``, so it can be released."""
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": sentence(name, claim, now),
95
+ "sentence": describe(name, claims, now),
91
96
  }
92
97
  if thing and thing.attrs:
93
98
  out["attrs"] = thing.attrs
94
- if claim is not None and claim.is_live(now):
95
- out["claim"] = claim.to_dict()
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.get(n), now) for n in names]}
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
- claim = store.get_claim(name.strip().lower())
163
- if thing is None and claim is None:
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.strip().lower(), thing, claim, now)
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(sentence(name, store.get_claim(name), datetime.now(UTC)) + "\n")
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 = sentence(thing.name, store.get_claim(thing.name), datetime.now(UTC))
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
- return {"things": [_view(a.thing, things.get(a.thing), a, now) for _, a in pairs]}
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), None, now) for c in released]}
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]:
@@ -41,6 +41,7 @@ EXIT_ERROR = 2
41
41
 
42
42
  DEFAULT_PORT = 8090
43
43
  UNIT_NAME = "dibs.service"
44
+ SERVICE_USER = "dibs"
44
45
 
45
46
  _DURATION_RE = re.compile(r"^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$")
46
47
  _DEFAULT_KEYS = ("id_ed25519.pub", "id_ecdsa.pub", "id_rsa.pub")
@@ -405,10 +406,12 @@ ConfigurationDirectory=dibs
405
406
  Restart=on-failure"""
406
407
  target = "default.target"
407
408
  else:
408
- # DynamicUser gives the service a throwaway system user that owns
409
- # only its StateDirectory, so it needs no account set up.
409
+ # A static user rather than DynamicUser=: ssh-keygen looks its own
410
+ # user up, and a dynamic user resolves only where nss-systemd is set
411
+ # up, which plain Debian does not do.
410
412
  service = f"""ExecStart={serve}
411
- DynamicUser=yes
413
+ User={SERVICE_USER}
414
+ Group={SERVICE_USER}
412
415
  StateDirectory=dibs
413
416
  ConfigurationDirectory=dibs
414
417
  Restart=on-failure
@@ -444,6 +447,25 @@ def _systemctl(user: bool, *argv: str) -> None:
444
447
  raise CliError(f"{' '.join(cmd)} failed")
445
448
 
446
449
 
450
+ def ensure_service_user() -> None:
451
+ """Create the ``dibs`` system user the system service runs as, once."""
452
+ if subprocess.run(["getent", "passwd", SERVICE_USER], capture_output=True).returncode == 0:
453
+ return
454
+ cmd = [
455
+ "useradd",
456
+ "--system",
457
+ "--user-group",
458
+ "--home-dir",
459
+ "/var/lib/dibs",
460
+ "--no-create-home",
461
+ "--shell",
462
+ "/usr/sbin/nologin",
463
+ SERVICE_USER,
464
+ ]
465
+ if subprocess.run(cmd).returncode != 0:
466
+ raise CliError(f"{' '.join(cmd)} failed; run dibs server install as root")
467
+
468
+
447
469
  def cmd_server_install(args: argparse.Namespace) -> None:
448
470
  exe = _exe()
449
471
  if not args.user and str(exe).startswith(str(Path.home())):
@@ -455,11 +477,16 @@ def cmd_server_install(args: argparse.Namespace) -> None:
455
477
  if args.print:
456
478
  sys.stdout.write(text)
457
479
  return
480
+ if not args.user:
481
+ ensure_service_user()
458
482
  path = unit_path(args.user)
459
483
  path.parent.mkdir(parents=True, exist_ok=True)
460
484
  path.write_text(text)
461
485
  _systemctl(args.user, "daemon-reload")
462
- _systemctl(args.user, "enable", "--now", UNIT_NAME)
486
+ _systemctl(args.user, "enable", UNIT_NAME)
487
+ # Restart rather than start, so installing over a running service, as
488
+ # an upgrade does, replaces the process with the new code and unit.
489
+ _systemctl(args.user, "restart", UNIT_NAME)
463
490
  print(f"Installed {path} and started it; dibs listens on port {args.port}.")
464
491
  if args.user:
465
492
  print("To keep it running while you are logged out: loginctl enable-linger")
@@ -521,6 +548,25 @@ def cmd_admin_revoke(args: argparse.Namespace) -> None:
521
548
  print(f"Revoked {count} token(s) of '{args.name}'; they must log in again.")
522
549
 
523
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
+
524
570
  # ---- shell completion ------------------------------------------------------
525
571
 
526
572
  # Commands whose positional arguments are thing names, which the
@@ -703,6 +749,11 @@ def build_parser() -> argparse.ArgumentParser:
703
749
  user_mode(p)
704
750
  p.set_defaults(func=func)
705
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
+
706
757
  p = sub.add_parser("completion", help="print a shell completion script")
707
758
  p.add_argument("shell", choices=["bash"])
708
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'")
@@ -128,7 +128,7 @@ def test_system_unit_runs_this_dibs_sandboxed() -> None:
128
128
  text = cli.unit_text(Path("/usr/local/bin/dibs"), user=False, host="0.0.0.0", port=8090)
129
129
  assert "ExecStart=/usr/local/bin/dibs serve --host 0.0.0.0 --port 8090\n" in text
130
130
  for line in (
131
- "DynamicUser=yes",
131
+ "User=dibs",
132
132
  "StateDirectory=dibs",
133
133
  "ProtectHome=yes",
134
134
  "WantedBy=multi-user.target",
@@ -155,7 +155,8 @@ def test_server_install_writes_enables_and_controls(
155
155
  assert "--port 9000 --user" in unit.read_text()
156
156
  assert calls == [
157
157
  ["systemctl", "--user", "daemon-reload"],
158
- ["systemctl", "--user", "enable", "--now", "dibs.service"],
158
+ ["systemctl", "--user", "enable", "dibs.service"],
159
+ ["systemctl", "--user", "restart", "dibs.service"],
159
160
  ]
160
161
  calls.clear()
161
162
  cli.main(["server", "stop", "--user"])
@@ -175,6 +176,29 @@ def test_server_install_writes_enables_and_controls(
175
176
  ]
176
177
 
177
178
 
179
+ def test_system_unit_avoids_dynamic_user() -> None:
180
+ """ssh-keygen needs its user to resolve, and a DynamicUser= user does
181
+ not resolve without nss-systemd (plain Debian has none)."""
182
+ text = cli.unit_text(Path("/usr/local/bin/dibs"), user=False, host="0.0.0.0", port=8090)
183
+ assert "DynamicUser" not in text and "User=dibs\nGroup=dibs\n" in text
184
+
185
+
186
+ @pytest.mark.parametrize(("exists", "want_useradd"), [(True, False), (False, True)])
187
+ def test_service_user_is_created_once(
188
+ monkeypatch: pytest.MonkeyPatch, exists: bool, want_useradd: bool
189
+ ) -> None:
190
+ calls: list[list[str]] = []
191
+
192
+ def fake_run(cmd: list[str], *a: Any, **kw: Any) -> subprocess.CompletedProcess[str]:
193
+ calls.append(cmd)
194
+ return subprocess.CompletedProcess(cmd, 0 if exists or cmd[0] != "getent" else 2)
195
+
196
+ monkeypatch.setattr(cli.subprocess, "run", fake_run)
197
+ cli.ensure_service_user()
198
+ assert calls[0] == ["getent", "passwd", "dibs"]
199
+ assert any(c[0] == "useradd" and c[-1] == "dibs" for c in calls) == want_useradd
200
+
201
+
178
202
  def test_system_install_refuses_a_dibs_in_home(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
179
203
  monkeypatch.setattr(cli, "_exe", lambda: Path.home() / ".local" / "bin" / "dibs")
180
204
  assert cli.main(["server", "install"]) == cli.EXIT_ERROR
@@ -235,3 +259,29 @@ def test_bash_completion(tmp_path: Path, capsys) -> None:
235
259
  assert _complete(script, "dibs claim --e", tmp_path) == ["--expires"]
236
260
  assert _complete(script, "dibs claim w", tmp_path) == ["warp"]
237
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
+ )
@@ -1,23 +0,0 @@
1
- <!--
2
- SPDX-FileCopyrightText: Simon A. F. Lund <os@safl.dk>
3
-
4
- SPDX-License-Identifier: BSD-3-Clause
5
- -->
6
-
7
- # Changelog
8
-
9
- Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
-
11
- ## [Unreleased]
12
-
13
- ## [0.1.0] - 2026-10-27
14
-
15
- ### Added
16
-
17
- - The first version: things to claim, fed from a config file and from
18
- pixie servers; claims that are all or nothing, never expire unless
19
- asked to, and say when an agent made them; users who register an SSH
20
- public key, are approved by the admin, and log in by signing a
21
- one-time challenge; a login hook that shows a machine's claim; and
22
- one `dibs` command for the server, its systemd service, the admin, and
23
- everyone claiming.
File without changes
File without changes