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.
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/.github/workflows/test.yml +21 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/.pre-commit-config.yaml +4 -0
- pixie_lab_dibs-0.2.0/CHANGELOG.md +49 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/Makefile +4 -1
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/PKG-INFO +41 -10
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/README.md +40 -9
- pixie_lab_dibs-0.2.0/scripts/service-smoke.sh +42 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/__init__.py +1 -1
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/_sources.py +10 -2
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/_store.py +69 -9
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/app.py +28 -15
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/cli.py +55 -4
- pixie_lab_dibs-0.2.0/src/dibs/skill/SKILL.md +65 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/test_app.py +23 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/test_cli.py +52 -2
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/test_sources.py +8 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/test_store.py +72 -0
- pixie_lab_dibs-0.1.0/CHANGELOG.md +0 -23
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/.github/workflows/format.yml +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/.github/workflows/publish.yml +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/.gitignore +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/LICENSE +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/LICENSES/BSD-3-Clause.txt +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/REUSE.toml +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/pyproject.toml +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/_sshsig.py +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/src/dibs/_users.py +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/__init__.py +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/conftest.py +0 -0
- {pixie_lab_dibs-0.1.0 → pixie_lab_dibs-0.2.0}/tests/test_users.py +0 -0
- {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.
|
|
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
|
|
85
|
-
user
|
|
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
|
-
|
|
149
|
-
|
|
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>`.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
63
|
-
user
|
|
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
|
-
|
|
127
|
-
|
|
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>`.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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"
|
|
@@ -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]:
|
|
@@ -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
|
-
#
|
|
409
|
-
#
|
|
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
|
-
|
|
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",
|
|
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
|
-
"
|
|
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", "
|
|
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
|
|
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
|