Compare commits
476
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53351dd30d | ||
|
|
32f1566cc8 | ||
|
|
0041b69d17 | ||
|
|
d1a15ced6c | ||
|
|
afa825f902 | ||
|
|
064ebc9b84 | ||
|
|
8ae4fabfbe | ||
|
|
aa0a472b29 | ||
|
|
9787223509 | ||
|
|
d15502f365 | ||
|
|
58664eee60 | ||
|
|
1ff904f101 | ||
|
|
2d592c63c1 | ||
|
|
87f8c30a09 | ||
|
|
e79392d666 | ||
|
|
5f372ac63a | ||
|
|
008e02afbd | ||
|
|
605c1b1ca0 | ||
|
|
7831f14a6e | ||
|
|
8927d68900 | ||
|
|
ec27f12524 | ||
|
|
c6844fc951 | ||
|
|
9116067e11 | ||
|
|
5ab8bb7e62 | ||
|
|
97bdb7d656 | ||
|
|
c0067e77fc | ||
|
|
8aad282e6c | ||
|
|
dd1a4e083c | ||
|
|
23256413de | ||
|
|
a357a85faa | ||
|
|
316039ca95 | ||
|
|
06d83912cd | ||
|
|
1bea6edcb8 | ||
|
|
2fb1e5c4ea | ||
|
|
a10bf45ed9 | ||
|
|
0fa4b5d8e7 | ||
|
|
3cb2b83a15 | ||
|
|
2b699d4046 | ||
|
|
117f933dac | ||
|
|
bb96fbbb3c | ||
|
|
75d98dd908 | ||
|
|
f6eb5d9efa | ||
|
|
f112011727 | ||
|
|
0aa5d6637c | ||
|
|
e271757db7 | ||
|
|
a6ce8dcbd6 | ||
|
|
c100e16ba0 | ||
|
|
b9abfde492 | ||
|
|
f7616c6a9b | ||
|
|
62c0eb90cb | ||
|
|
5795f4d230 | ||
|
|
a60728e6f0 | ||
|
|
792a79720b | ||
|
|
d638856768 | ||
|
|
d22a7f3a41 | ||
|
|
ec646f55ee | ||
|
|
49ab6c00ea | ||
|
|
45b0779b68 | ||
|
|
0ec8347786 | ||
|
|
52572698cb | ||
|
|
700b37890b | ||
|
|
ea4bb70a21 | ||
|
|
87ca34c92f | ||
|
|
836fcd81f6 | ||
|
|
8484ab0032 | ||
|
|
c29ef0d95e | ||
|
|
c0d666fdcf | ||
|
|
22bbb9c2c0 | ||
|
|
7b50628627 | ||
|
|
23a280a664 | ||
|
|
3bb8ace833 | ||
|
|
ab1eb0cb47 | ||
|
|
2a9827ce18 | ||
|
|
493c59c89b | ||
|
|
1cb1c84cc1 | ||
|
|
fdd33e1234 | ||
|
|
c41387f271 | ||
|
|
e034f73991 | ||
|
|
ee6d7c284d | ||
|
|
622b259145 | ||
|
|
b5c5594ea7 | ||
|
|
64fa2f5abf | ||
|
|
f1c04801e4 | ||
|
|
fff3b819d3 | ||
|
|
dad8672b73 | ||
|
|
b854f884e9 | ||
|
|
a57139dcf5 | ||
|
|
f7e59b876d | ||
|
|
b1da6344a8 | ||
|
|
0f796e6505 | ||
|
|
6d000647de | ||
|
|
cff1005422 | ||
|
|
0e9fb2cbe3 | ||
|
|
478551622d | ||
|
|
742303895b | ||
|
|
ce9f984363 | ||
|
|
c82b40660b | ||
|
|
e436c59430 | ||
|
|
fe56de746e | ||
|
|
8261c2c7e4 | ||
|
|
b276da8ba6 | ||
|
|
f18e24ce19 | ||
|
|
466e1ae320 | ||
|
|
cc21062250 | ||
|
|
f9dcbc2957 | ||
|
|
a196bf6323 | ||
|
|
94a0ebbedf | ||
|
|
e9cdf08841 | ||
|
|
0be81ae036 | ||
|
|
c5f6845386 | ||
|
|
2dc2c7e431 | ||
|
|
9e005317dd | ||
|
|
4226204e57 | ||
|
|
546b674d14 | ||
|
|
4caea7e624 | ||
|
|
636cb633ac | ||
|
|
6181d9afb3 | ||
|
|
89ad370485 | ||
|
|
333e3c21a5 | ||
|
|
8291502cbb | ||
|
|
8d2497b65e | ||
|
|
5b77eb18f8 | ||
|
|
aa10f070f2 | ||
|
|
9ee1cbe3f5 | ||
|
|
b52dd7ca44 | ||
|
|
bf60d6a8bb | ||
|
|
584fc94baa | ||
|
|
11f49905a1 | ||
|
|
51df346a23 | ||
|
|
c1008aeedf | ||
|
|
8d73b5ba1b | ||
|
|
48a9402ca0 | ||
|
|
ee56d56922 | ||
|
|
af0d58fc6f | ||
|
|
fea509d3f5 | ||
|
|
ff6a8928d6 | ||
|
|
9fe3f41ac0 | ||
|
|
48ad5ce947 | ||
|
|
f339ea5936 | ||
|
|
64669575c8 | ||
|
|
e4ef67adcd | ||
|
|
e723d498e7 | ||
|
|
a5e8bcca8a | ||
|
|
4f7c0ba534 | ||
|
|
00be4c5063 | ||
|
|
d708998a53 | ||
|
|
2d5358b8b9 | ||
|
|
741239a506 | ||
|
|
c235abcb11 | ||
|
|
dcaa229c2b | ||
|
|
0e3580895d | ||
|
|
8d6b7fbbc9 | ||
|
|
f8160922c9 | ||
|
|
904b8aedd4 | ||
|
|
6277067465 | ||
|
|
177115d6cd | ||
|
|
78b12be752 | ||
|
|
f17001d920 | ||
|
|
24778d24b6 | ||
|
|
247c422fbf | ||
|
|
ea87dd80bb | ||
|
|
0699741fab | ||
|
|
476fc0cc30 | ||
|
|
75514e05e2 | ||
|
|
15db34169e | ||
|
|
500c8c8cec | ||
|
|
6c2d6605a5 | ||
|
|
033ff2540b | ||
|
|
ebcc7761f1 | ||
|
|
bf7d040489 | ||
|
|
80ade3cacb | ||
|
|
3ebcb15a4b | ||
|
|
1ed0dca7df | ||
|
|
810254193c | ||
|
|
ba3fd46e82 | ||
|
|
dafa0691ad | ||
|
|
22a90d4267 | ||
|
|
739c0c23c6 | ||
|
|
a81f5ed0e3 | ||
|
|
c3e75e35e1 | ||
|
|
3a6a127c38 | ||
|
|
737cb95c47 | ||
|
|
d854f936f9 | ||
|
|
9e41f6380a | ||
|
|
4cd0b42d80 | ||
|
|
bd2997cf36 | ||
|
|
6d45f3ef58 | ||
|
|
968a0aac7a | ||
|
|
572a2b3a4b | ||
|
|
2dd02da76e | ||
|
|
0ebabf7174 | ||
|
|
7d755c8f08 | ||
|
|
b43c57f43b | ||
|
|
d9f2b7bcfa | ||
|
|
c6de76740f | ||
|
|
dbcba161d5 | ||
|
|
c8e321c8e1 | ||
|
|
b8e8be619d | ||
|
|
cc8a354f75 | ||
|
|
6a57bb7218 | ||
|
|
5d1456257c | ||
|
|
36d9d73984 | ||
|
|
0acad94ba5 | ||
|
|
056e4ea915 | ||
|
|
9cf5c1ddbd | ||
|
|
3b92e02862 | ||
|
|
a8131809b2 | ||
|
|
b8ce072c9f | ||
|
|
7b16e195ee | ||
|
|
baaf2e56dd | ||
|
|
4b749286bd | ||
|
|
ebaa4c9946 | ||
|
|
231975bce6 | ||
|
|
35fb415f7b | ||
|
|
14d1a919cc | ||
|
|
20e92dacd5 | ||
|
|
9e545658a5 | ||
|
|
4e10ab7b2e | ||
|
|
954e3afe56 | ||
|
|
605c583ab4 | ||
|
|
44cb7ad449 | ||
|
|
6cf98386e9 | ||
|
|
bd65b023bd | ||
|
|
31ce47d1cc | ||
|
|
6eccfc286a | ||
|
|
110c631b5f | ||
|
|
256a412590 | ||
|
|
e0428bd874 | ||
|
|
da5bbe0a17 | ||
|
|
b63e8ae58a | ||
|
|
3991211993 | ||
|
|
9cc62818f4 | ||
|
|
7278e509f4 | ||
|
|
0098960b5a | ||
|
|
c41a84ee95 | ||
|
|
ff07c3a668 | ||
|
|
7df879b367 | ||
|
|
095e23ecda | ||
|
|
2b604e0dad | ||
|
|
9770dab09e | ||
|
|
15094d6e72 | ||
|
|
5ca3876bea | ||
|
|
f29a9ad563 | ||
|
|
4179be7a56 | ||
|
|
e85cf002a4 | ||
|
|
8f3c810fc3 | ||
|
|
c854373840 | ||
|
|
49bace0073 | ||
|
|
1c03299721 | ||
|
|
e4c5108306 | ||
|
|
e995e40519 | ||
|
|
a9dfe77b1d | ||
|
|
caa350f350 | ||
|
|
673b9d075a | ||
|
|
47aaee122c | ||
|
|
22b50c88a9 | ||
|
|
265d3b83b2 | ||
|
|
72ccb75c1b | ||
|
|
790b5aac7d | ||
|
|
b15c511528 | ||
|
|
d4e29a07b2 | ||
|
|
d627400cf1 | ||
|
|
41567adee0 | ||
|
|
f5ce980808 | ||
|
|
0bd978f4ee | ||
|
|
af978cc559 | ||
|
|
6e343831c9 | ||
|
|
5b4b201558 | ||
|
|
f509c61b50 | ||
|
|
469763547c | ||
|
|
9aae6244d5 | ||
|
|
6a612c58cb | ||
|
|
d03ffa8038 | ||
|
|
f06dd01bfe | ||
|
|
c81915b039 | ||
|
|
839147b146 | ||
|
|
14699713b9 | ||
|
|
652493e2ac | ||
|
|
fc83f204f4 | ||
|
|
3eee45aa49 | ||
|
|
9f4d4eecba | ||
|
|
f0c7d468c3 | ||
|
|
18fd2a32bf | ||
|
|
7cc4a33cc5 | ||
|
|
8255640d00 | ||
|
|
3215e5eeb9 | ||
|
|
1cc67aa9d7 | ||
|
|
06ebd7e8cf | ||
|
|
cd24677756 | ||
|
|
05fcebf9c5 | ||
|
|
95f29d6c54 | ||
|
|
a587125047 | ||
|
|
ab5423f87f | ||
|
|
cbd4da3a11 | ||
|
|
b78e90c0c7 | ||
|
|
11d1be3ef8 | ||
|
|
9677f200f3 | ||
|
|
dd4449de61 | ||
|
|
846291069b | ||
|
|
d786477a0d | ||
|
|
b90d3a4556 | ||
|
|
9c1cb63c3b | ||
|
|
fa0f851f81 | ||
|
|
65570636b2 | ||
|
|
1af839fb72 | ||
|
|
ecf597adce | ||
|
|
811b59b454 | ||
|
|
8f4f70f088 | ||
|
|
ef60e5d09e | ||
|
|
26c82a507a | ||
|
|
76be864bff | ||
|
|
9e2311029a | ||
|
|
bea1c446ea | ||
|
|
1e55ea8e4e | ||
|
|
9f62e0a2ba | ||
|
|
579cd33cd5 | ||
|
|
d3286ad0eb | ||
|
|
a7b83b27f0 | ||
|
|
4839884d93 | ||
|
|
6d4ad2c249 | ||
|
|
6f7d070769 | ||
|
|
85023b3b52 | ||
|
|
8484d349aa | ||
|
|
1f1aa70580 | ||
|
|
46c125c07c | ||
|
|
435d1206e4 | ||
|
|
6fe1bba067 | ||
|
|
ed188268a0 | ||
|
|
bf6bb3d094 | ||
|
|
8ee28d08c3 | ||
|
|
6ac5c934d2 | ||
|
|
ee9c389888 | ||
|
|
59f9a00c64 | ||
|
|
badfe0d166 | ||
|
|
64ed69db9b | ||
|
|
c6cf8b64f1 | ||
|
|
32528aa58f | ||
|
|
00b95f1f68 | ||
|
|
a9fa004758 | ||
|
|
eb7a25622d | ||
|
|
8a6827dfe2 | ||
|
|
30a13c4ebb | ||
|
|
11daf2f289 | ||
|
|
3f25e5b4da | ||
|
|
cb5a8dd98e | ||
|
|
d3781fd840 | ||
|
|
cf383af441 | ||
|
|
bb1e18f490 | ||
|
|
663d65640d | ||
|
|
119e0a6f13 | ||
|
|
202c44c6e3 | ||
|
|
7a1e8d1036 | ||
|
|
299f43167c | ||
|
|
713b59633e | ||
|
|
86342c4399 | ||
|
|
bc2e5574ef | ||
|
|
a24f7809a6 | ||
|
|
6062feaa22 | ||
|
|
076f63f932 | ||
|
|
fc90e6c67b | ||
|
|
5c8f3b8351 | ||
|
|
405729127d | ||
|
|
ca02942a29 | ||
|
|
6acc747661 | ||
|
|
b6884796e7 | ||
|
|
fcff8cf35e | ||
|
|
a96fd44abb | ||
|
|
c2e017a4c1 | ||
|
|
bbd9f638a0 | ||
|
|
d64e9f3818 | ||
|
|
b9a224dc99 | ||
|
|
b8c3f5f09a | ||
|
|
e6aab55008 | ||
|
|
312d824e4f | ||
|
|
5daf3a333a | ||
|
|
a00429bc80 | ||
|
|
975f1aa8e8 | ||
|
|
0bf636826a | ||
|
|
9dfb2eae0e | ||
|
|
b471b5cf9e | ||
|
|
32655b95f6 | ||
|
|
b5aebbbe91 | ||
|
|
4ad5f40817 | ||
|
|
b7dab47e88 | ||
|
|
dfb682fa33 | ||
|
|
357967308f | ||
|
|
25bbd55539 | ||
|
|
cfad86394f | ||
|
|
c62650e4ff | ||
|
|
fe8c41928d | ||
|
|
50677b204b | ||
|
|
ae056a44dd | ||
|
|
ea74be9e2e | ||
|
|
19994c1817 | ||
|
|
3c0b4d29a8 | ||
|
|
7cc81512a3 | ||
|
|
462db1c257 | ||
|
|
4a3914ee1c | ||
|
|
ce3284fc8f | ||
|
|
ad6e813fb0 | ||
|
|
b373e34b29 | ||
|
|
067b9a47da | ||
|
|
bfa4a0851a | ||
|
|
b7f78d47c5 | ||
|
|
c4463ba47f | ||
|
|
c39503e322 | ||
|
|
6c61d9facf | ||
|
|
a39b72c377 | ||
|
|
e712cd227f | ||
|
|
12059ad3cc | ||
|
|
6a29e41175 | ||
|
|
1948da9cb4 | ||
|
|
b84a2f4dc1 | ||
|
|
9c42423d58 | ||
|
|
c12f1a16db | ||
|
|
09ea4a40bf | ||
|
|
6b8e7fc621 | ||
|
|
3c3fdd3446 | ||
|
|
06f28fbadb | ||
|
|
62644aa012 | ||
|
|
bdfd8db265 | ||
|
|
89ec9429db | ||
|
|
b9cfa4fe16 | ||
|
|
8b184d8322 | ||
|
|
02d57c4a53 | ||
|
|
5a7561fe1d | ||
|
|
5e537f851f | ||
|
|
0a6a5aba68 | ||
|
|
407fc6c9e6 | ||
|
|
6299b47f4f | ||
|
|
24bf59219e | ||
|
|
e3b4ecde8c | ||
|
|
fd9147087e | ||
|
|
a3d141e793 | ||
|
|
4fbe6e347f | ||
|
|
3d70bd3660 | ||
|
|
a4e5a8ffef | ||
|
|
13dea6b1aa | ||
|
|
6d2ce96630 | ||
|
|
a7dc4534d7 | ||
|
|
4387e77f28 | ||
|
|
443a340f06 | ||
|
|
15f81780b9 | ||
|
|
ea4dfaa5a2 | ||
|
|
7630e3cc9d | ||
|
|
994b25f540 | ||
|
|
503411e628 | ||
|
|
4e2a1a5a1f | ||
|
|
6a24e4d0c7 | ||
|
|
80186d0798 | ||
|
|
89cd8a2e8e | ||
|
|
4658e1cb1c | ||
|
|
6e6b325cf1 | ||
|
|
bbe107fa2a | ||
|
|
ac94f8338f | ||
|
|
a0459a8553 | ||
|
|
6cf7f67a1d | ||
|
|
08fbc51af2 | ||
|
|
1ef1b13560 | ||
|
|
7fd4ed5898 | ||
|
|
bc8cb3b153 | ||
|
|
9480557537 | ||
|
|
c88be88ccf | ||
|
|
53d173aa7d | ||
|
|
4becc7bd83 | ||
|
|
61737962fc | ||
|
|
b4192c525e | ||
|
|
d03b455ea6 | ||
|
|
1171b684d0 | ||
|
|
6547933b4d | ||
|
|
0cd706b044 | ||
|
|
908f3918de | ||
|
|
8b84ac7487 | ||
|
|
9698bcd934 | ||
|
|
21aa11aef1 | ||
|
|
145cf7e33a |
+4
-4
@@ -43,7 +43,10 @@ dist/
|
||||
/modules/claude-code/claude-code
|
||||
/modules/claude-code/cmd/claude-code/claude-code
|
||||
/modules/claude-licence-manager/claude-licence-manager
|
||||
/modules/claude-licence-manager/cmd/claude-licence-manager/claude-licence-manager
|
||||
/modules/claude-licence-manager/cmd/manager/manager
|
||||
/modules/claude-licence-manager/cmd/tools/tools
|
||||
/modules/claude-licence-manager/manager
|
||||
/modules/claude-licence-manager/tools
|
||||
/modules/clipmenu/clipmenu-tools
|
||||
/modules/clipmenu/cmd/clipmenu-tools/clipmenu-tools
|
||||
/modules/cups/cmd/cups-tools/cups-tools
|
||||
@@ -112,9 +115,6 @@ dist/
|
||||
/modules/mailu/cmd/automx/automx
|
||||
/modules/memory-pressure/cmd/memory-pressure/memory-pressure
|
||||
/modules/memory-pressure/memory-pressure
|
||||
/modules/mesh-build-go/buildstep/cmd/mesh-pack/mesh-pack
|
||||
/modules/mesh-build-go/buildstep/cmd/seatgen/seatgen
|
||||
/modules/mesh-build-go/buildstep/mesh-pack
|
||||
/modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
|
||||
// exempt from that, and said so. Every build module, test step or not, must also hold a test that calls
|
||||
// conformance.LeftRunning with its own start sweep, so a step a previous process left running is ended at its start
|
||||
// (issue 1890).
|
||||
// (issue 1890), and one that calls conformance.Passing, so a step whose network did not answer is said as a transport fault
|
||||
// (issue 1748). Its manifest asks the capability seat-settle-delay (ADR 0664 §5).
|
||||
package failurekit
|
||||
|
||||
import (
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -27,9 +29,15 @@ type manifest struct {
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"claims"`
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
// settleDelay is the capability a tool runner states when it can settle a seat's ask with a delayed negative acknowledgement
|
||||
// (mesh-sdk CapSeatSettleDelay): a build module gives its ask again after a delay by it, so the delivery plan places a tool
|
||||
// runner that states it before the module (ADR 0664 §1, §5).
|
||||
const settleDelay = "seat-settle-delay"
|
||||
|
||||
// Judge reads every modules/<m>/module.json of the catalogue at root and judges each module that claims a node-build-*
|
||||
// seat.
|
||||
func Judge(root string) ([]Judged, error) {
|
||||
@@ -83,6 +91,22 @@ func Judge(root string) ([]Judged, error) {
|
||||
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
|
||||
"(mesh-build-go buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
|
||||
}
|
||||
// Every build module meets the network (a fetch, a download, an install, a publish), so every one runs the passing case: its
|
||||
// steps say a network that did not answer as a transport fault the bus gives the ask again for (ADR 0664, issue 1748).
|
||||
says, err := runsTheKit(dir, "conformance.Passing(")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !says {
|
||||
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.Passing "+
|
||||
"(mesh-build-go buildstep/conformance): run the kit's passing case with the module's own path from a failed run to its step's error (issue 1748)", seat))
|
||||
}
|
||||
// Every build module gives a step that met a transport fault again by the bus (ADR 0664 §5): it asks the tool runner's
|
||||
// capability in its manifest, so the delivery plan places a tool runner that states it first.
|
||||
if !slices.Contains(m.Capabilities, settleDelay) {
|
||||
refused = append(refused, fmt.Sprintf("it claims %s, and its manifest's capabilities do not ask %s: a step given again by the bus needs "+
|
||||
"a tool runner that can settle with a delay (ADR 0664 §5, issue 1748)", seat, settleDelay))
|
||||
}
|
||||
j.Refused = strings.Join(refused, "; ")
|
||||
out = append(out, j)
|
||||
}
|
||||
@@ -121,7 +145,7 @@ func runsTheKit(dir, call string) (bool, error) {
|
||||
return err
|
||||
}
|
||||
found = strings.Contains(string(raw), call) &&
|
||||
strings.Contains(string(raw), `"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance"`)
|
||||
strings.Contains(string(raw), `"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance"`)
|
||||
return nil
|
||||
})
|
||||
return found, err
|
||||
|
||||
@@ -8,23 +8,28 @@ import (
|
||||
|
||||
// 1901/AC7 and 1890/AC8: a build module with a test step and no kit is refused, one that runs the kit passes, one with no
|
||||
// test step is exempt from the failure cases but not from the left-running case, a module that runs only the failure
|
||||
// cases is refused, and a module that claims no node-build-* seat is not judged.
|
||||
// cases is refused, a module that does not ask the capability seat-settle-delay is refused (ADR 0664 §5), and a module that
|
||||
// claims no node-build-* seat is not judged.
|
||||
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance\"\n\n"
|
||||
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n"
|
||||
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance\"\n\n"
|
||||
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\nfunc TestPassing(t *testing.T) { conformance.Passing(t, nil, nil) }\n"
|
||||
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
|
||||
for path, body := range map[string]string{
|
||||
"modules/a/module.json": `{"claims":[{"name":"node-build-a"}],"tools":["build_a_fetch","build_a_test"]}`,
|
||||
"modules/a/cmd/a/check_test.go": "package main\n",
|
||||
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"tools":[{"name":"build_b_test"}]}`,
|
||||
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"capabilities":["seat-settle-delay"],"tools":[{"name":"build_b_test"}]}`,
|
||||
"modules/b/cmd/b/kit_test.go": kit,
|
||||
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"tools":["build_c_build"]}`,
|
||||
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"capabilities":["seat-settle-delay"],"tools":["build_c_build"]}`,
|
||||
"modules/c/cmd/c/kit_test.go": imp + sweep,
|
||||
"modules/f/module.json": `{"claims":[{"name":"node-build-f"}],"tools":["build_f_build"]}`,
|
||||
"modules/g/module.json": `{"claims":[{"name":"node-build-g"}],"tools":["build_g_test"]}`,
|
||||
"modules/g/cmd/g/kit_test.go": imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n",
|
||||
"modules/d/module.json": `{"claims":[{"name":"other"}],"tools":["d_test"]}`,
|
||||
"modules/h/module.json": `{"claims":[{"name":"node-build-h"}],"tools":["build_h_build"]}`, // the sweep, and no passing case
|
||||
"modules/h/cmd/h/kit_test.go": imp + "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n",
|
||||
"modules/i/module.json": `{"claims":[{"name":"node-build-i"}],"capabilities":["other"],"tools":["build_i_build"]}`, // the whole kit, and no seat-settle-delay
|
||||
"modules/i/cmd/i/kit_test.go": imp + sweep,
|
||||
"modules/e/module.json": `{"claims":[{"name":"node-build-e"}],"tools":["build_e_test"]}`,
|
||||
"modules/e/cmd/e/kit.go": kit, // not a test file
|
||||
} {
|
||||
@@ -40,9 +45,9 @@ func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused", "f": "refused", "g": "refused"}
|
||||
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused", "f": "refused", "g": "refused", "h": "refused", "i": "refused"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("judged %+v, want a, b, c, e, f and g", got)
|
||||
t.Fatalf("judged %+v, want a, b, c, e, f, g, h and i", got)
|
||||
}
|
||||
for _, j := range got {
|
||||
is := "passes"
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go
|
||||
// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages.
|
||||
// ownDependencies): the `dependencies` beyond the mesh's own packages (@novox/mesh-*, the SDK among them), typescript and the
|
||||
// @types packages.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -17,6 +18,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// meshPackages is the prefix of every package the mesh itself publishes: the build module's toolchain and the mesh's registry
|
||||
// give them, as they give the SDK, so a module naming only them needs no lock (design 47 G A-56).
|
||||
const meshPackages = "@novox/mesh-"
|
||||
|
||||
// Finding is one module refused: it names Packages beyond the SDK and has no lock file.
|
||||
type Finding struct {
|
||||
Module string
|
||||
@@ -70,7 +75,7 @@ func Check(root string) ([]Finding, error) {
|
||||
}
|
||||
var names []string
|
||||
for n := range pkg.Dependencies {
|
||||
if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") {
|
||||
if !strings.HasPrefix(n, meshPackages) && n != "typescript" && !strings.HasPrefix(n, "@types/") {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
module(t, root, "plain", "typescript", sdkOnly, false)
|
||||
module(t, root, "none", "typescript", "", false)
|
||||
// The mesh's own packages count as the SDK does (design 47 G A-56): a contracts package needs no lock.
|
||||
module(t, root, "contracts", "typescript", `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","@novox/mesh-delivery-contracts-git":"^0.1.0"}}`, false)
|
||||
got, err := Check(root)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
|
||||
@@ -11,4 +11,4 @@ replace git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings => ../../
|
||||
|
||||
replace git.novox.be/novox/mesh-catalog/modules/restic/backupgood => ../../modules/restic/backupgood
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 // indirect
|
||||
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
|
||||
+44
-23
@@ -31,6 +31,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/modules/disk-load/diskreadings"
|
||||
"git.novox.be/novox/mesh-catalog/modules/restic/backupgood"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Known are the constants of the owners' published packages an announcement may name a state by, by the selector the
|
||||
@@ -53,6 +54,7 @@ func Findings(root string) ([]string, error) {
|
||||
}
|
||||
dirs := map[string]string{}
|
||||
reads := map[string][]string{}
|
||||
holders := map[string][]string{} // seat -> the modules of the catalogue that claim it
|
||||
for _, path := range manifests {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -61,11 +63,17 @@ func Findings(root string) ([]string, error) {
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Reads []json.RawMessage `json:"reads"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"claims"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
dirs[m.Module] = filepath.Dir(path)
|
||||
for _, c := range m.Claims {
|
||||
holders[c.Name] = append(holders[c.Name], m.Module)
|
||||
}
|
||||
for _, r := range m.Reads {
|
||||
var s string
|
||||
if json.Unmarshal(r, &s) == nil {
|
||||
@@ -77,32 +85,45 @@ func Findings(root string) ([]string, error) {
|
||||
owners := map[string]Announced{}
|
||||
for reader, rs := range reads {
|
||||
for _, r := range rs {
|
||||
owner, state, ok := strings.Cut(r, ".")
|
||||
dir, inCatalogue := dirs[owner]
|
||||
if !ok || !inCatalogue || owner == reader {
|
||||
continue
|
||||
}
|
||||
a, seen := owners[owner]
|
||||
if !seen {
|
||||
var problems []string
|
||||
a, problems, err = AnnouncedBy(dir)
|
||||
// A read in the seat form `seat:<seat>.<state>` (design 47 G A-116, A-128) is of the holder of the seat, found where it
|
||||
// is read: each module of the catalogue claiming the seat is judged for the state, as a read of it by name is.
|
||||
var targets []string
|
||||
state := ""
|
||||
if seat, local, isSeat, err := catalogue.SeatRead(r); isSeat {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
continue // the manifest validator names a malformed seat read
|
||||
}
|
||||
owners[owner] = a
|
||||
for _, p := range problems {
|
||||
out = append(out, owner+": "+p)
|
||||
targets, state = holders[seat], local
|
||||
} else if owner, local, ok := strings.Cut(r, "."); ok {
|
||||
targets, state = []string{owner}, local
|
||||
}
|
||||
for _, owner := range targets {
|
||||
dir, inCatalogue := dirs[owner]
|
||||
if !inCatalogue || owner == reader {
|
||||
continue
|
||||
}
|
||||
a, seen := owners[owner]
|
||||
if !seen {
|
||||
var problems []string
|
||||
a, problems, err = AnnouncedBy(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owners[owner] = a
|
||||
for _, p := range problems {
|
||||
out = append(out, owner+": "+p)
|
||||
}
|
||||
}
|
||||
if a == nil {
|
||||
continue // announces nothing from code: read as before
|
||||
}
|
||||
readable, keeps := a[state]
|
||||
switch {
|
||||
case !keeps:
|
||||
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code does not announce: the read is refused on the mesh", reader, owner, state, owner))
|
||||
case !readable:
|
||||
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code announces not readable: the read is refused on the mesh; announce it readable", reader, owner, state, owner))
|
||||
}
|
||||
}
|
||||
if a == nil {
|
||||
continue // announces nothing from code: read as before
|
||||
}
|
||||
readable, keeps := a[state]
|
||||
switch {
|
||||
case !keeps:
|
||||
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code does not announce: the read is refused on the mesh", reader, owner, state, owner))
|
||||
case !readable:
|
||||
out = append(out, fmt.Sprintf("%s reads %s.%s, which %s's code announces not readable: the read is refused on the mesh; announce it readable", reader, owner, state, owner))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,15 @@ func TestFindings(t *testing.T) {
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.bound"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
"modules/o/index.ts": `await keeps([{ name: "bound", readable: true }]);`}, ""},
|
||||
{"seat form, the holder's state not readable", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["seat:the-seat.s"]}`,
|
||||
"modules/o/module.json": `{"module":"o","claims":[{"name":"the-seat","scope":"mesh"}]}`,
|
||||
"modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: \"s\"}\n"},
|
||||
"r reads o.s, which o's code announces not readable"},
|
||||
{"seat form, the holder's state readable, and a seat no module of the catalogue holds", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["seat:the-seat.s","seat:elsewhere.s"]}`,
|
||||
"modules/o/module.json": `{"module":"o","claims":[{"name":"the-seat","scope":"mesh"}]}`,
|
||||
"modules/o/cmd/o/main.go": goOwner + "var _ = announce.State{Name: \"s\", Readable: true}\n"}, ""},
|
||||
{"an owner that announces nothing, and one outside the catalogue", map[string]string{
|
||||
"modules/r/module.json": `{"module":"r","reads":["o.s","mesh-controller.lease"]}`,
|
||||
"modules/o/module.json": `{"module":"o"}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Command removedpackages refuses a Go source of the catalogue that imports what mesh-sdk gave up in the release that
|
||||
// narrowed it (novox/hq ADR 0593 §1): a package that left for its owner, and the build runner's names that left
|
||||
// mesh-sdk's buildstep for mesh-build-go's package.
|
||||
// mesh-sdk's buildstep for the build runner's package (now the delivery SDK's steprun).
|
||||
//
|
||||
// A module compiles against the mesh-sdk version it pins, so a source that still names a removed package builds until the
|
||||
// pin moves, and then breaks in a delivery of its own; and a later change could bring the import back with the old pin.
|
||||
@@ -8,8 +8,8 @@
|
||||
//
|
||||
// - an import of a package in Removed (or one under it);
|
||||
// - in a file importing mesh-sdk's buildstep (under any name), a selector naming anything that is not in
|
||||
// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is
|
||||
// mesh-build-go's package (git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep).
|
||||
// BuildstepContract: what is left of that package is what the delivery parts read, and the runner is the
|
||||
// delivery SDK's package steprun (git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun).
|
||||
//
|
||||
// It reads the non-test and test Go files under modules/ and checks/ (testdata, vendor and node_modules left out).
|
||||
//
|
||||
@@ -35,8 +35,8 @@ const sdk = "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
// Removed are the packages of mesh-sdk that left it, each with where its logic is now.
|
||||
var Removed = map[string]string{
|
||||
"buildcpu": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/buildcpu",
|
||||
"buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep/conformance",
|
||||
"buildcpu": "the build runner's package, git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/buildcpu",
|
||||
"buildstep/conformance": "the build runner's package, git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun/conformance",
|
||||
"delivery/buildrun": "the builder's package (mesh-delivery-builder), imported by its repository's modules",
|
||||
"compose": "the node-engine, which alone composes (ADR 0593 §6)",
|
||||
"compose/frombus": "the node-engine, which alone composes (ADR 0593 §6)",
|
||||
@@ -140,7 +140,7 @@ func fileFindings(path, rel string) ([]string, error) {
|
||||
return true
|
||||
}
|
||||
seen[sel.Sel.Name] = true
|
||||
out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name))
|
||||
out = append(out, fmt.Sprintf("%s: uses %s.%s, which is the build runner's, no longer mesh-sdk's buildstep; import git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun (novox/hq ADR 0593 §1)", rel, buildstep, sel.Sel.Name))
|
||||
return true
|
||||
})
|
||||
return out, nil
|
||||
|
||||
@@ -18,11 +18,11 @@ func TestFindingsRefuseWhatMeshSDKGaveUp(t *testing.T) {
|
||||
{"clean", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/delivery\"\n)\n\nvar _ = delivery.StepsState\n", ""},
|
||||
{"a removed package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n\nvar _ = compose.Resolve\n", "imports git.novox.be/novox/mesh-sdk/go/compose, which left mesh-sdk; it is the node-engine"},
|
||||
{"under a removed package", "modules/a/x_test.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose/frombus\"\n)\n", "the node-engine"},
|
||||
{"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "buildstep/buildcpu"},
|
||||
{"buildcpu", "checks/c/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildcpu\"\n)\n", "steprun/buildcpu"},
|
||||
{"the runner through mesh-sdk's buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", "uses buildstep.RunAsk, which is the build runner's"},
|
||||
{"the runner through an alias", "modules/a/x.go", head + "\tsdkstep \"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = sdkstep.Puller{}\n", "uses sdkstep.Puller"},
|
||||
{"the contract of buildstep", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/buildstep\"\n)\n\nvar _ = buildstep.NewID\nvar _ buildstep.Failure\n", ""},
|
||||
{"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep\"\n)\n\nvar _ = buildstep.RunAsk\n", ""},
|
||||
{"the runner's own package", "modules/a/x.go", head + "\t\"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/steprun\"\n)\n\nvar _ = steprun.RunAsk\n", ""},
|
||||
{"a copy in testdata", "modules/a/testdata/x.go", head + "\t\"git.novox.be/novox/mesh-sdk/go/compose\"\n)\n", ""},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "adwaita",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"moved": true,
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
|
||||
sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep"
|
||||
)
|
||||
|
||||
// The holders whose states this module reads, one constant each (a reader names its holder once; ADR 0634 §4).
|
||||
|
||||
@@ -20,21 +20,21 @@ import (
|
||||
"fmt"
|
||||
engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
@@ -45,8 +45,6 @@ const (
|
||||
HavesRead = "node-engine.have"
|
||||
// ManifestsRead is the indexer's manifests: the images a manifest pins by itself.
|
||||
ManifestsRead = indexerModule + "." + catalog.StateManifests
|
||||
// FindingsState is the holder's own findings (meshconditions.StateFindings): image-missing.
|
||||
FindingsState = meshconditions.StateFindings
|
||||
// Module is this module, the observer its findings name.
|
||||
Module = "artifact-store-tools"
|
||||
)
|
||||
@@ -83,8 +81,10 @@ type Door interface {
|
||||
|
||||
// Holder is the seat's holder.
|
||||
type Holder struct {
|
||||
Bus Bus
|
||||
Door func() (Door, error)
|
||||
Bus Bus
|
||||
// Findings is the module's finding writer: S25's and the sweep's findings go through it.
|
||||
Findings Findings
|
||||
Door func() (Door, error)
|
||||
// Listed is the store as its seats list it now (Store.View); nil reads none, and then nothing is said gone or collected.
|
||||
Listed func(ctx context.Context) (*View, error)
|
||||
Acts meshhandactsclient.Recorder
|
||||
@@ -95,6 +95,7 @@ type Holder struct {
|
||||
|
||||
sweep sync.Mutex // one sweep at a time
|
||||
mu sync.Mutex
|
||||
fmu sync.Mutex // one pass of Observe at a time
|
||||
ended map[string]time.Time // a done key's end, read once from its history
|
||||
}
|
||||
|
||||
@@ -852,16 +853,60 @@ func inStoreAt(image, address string) string {
|
||||
return r
|
||||
}
|
||||
|
||||
// Observe writes S25's findings (image-missing): each want whose build names an image the store does not hold, on the
|
||||
// want's machine, and deletes each finding that no longer holds. Nothing is said without a listing.
|
||||
// Observe says S25's findings (image-missing): each want whose build names an image the store does not hold, on the
|
||||
// want's machine, written once and cleared once it no longer holds. Nothing is said without a listing. What stands is what
|
||||
// the held writer holds (design 47 G A-32, A-38), so a restart neither drops nor rewrites it.
|
||||
func (h *Holder) Observe(ctx context.Context) error {
|
||||
j, err := h.judge(ctx)
|
||||
now, err := h.observed(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if j.view == nil {
|
||||
if now == nil {
|
||||
return nil
|
||||
}
|
||||
h.fmu.Lock()
|
||||
defer h.fmu.Unlock()
|
||||
said := map[string]meshconditions.Finding{}
|
||||
for _, f := range h.Findings.Held() {
|
||||
if f.Kind == ImageMissing {
|
||||
said[views.FindingKey(f.Scope, f.ID, f.Kind)] = f
|
||||
}
|
||||
}
|
||||
for k, was := range said {
|
||||
if _, still := now[k]; !still {
|
||||
if err := h.Findings.Clear(ctx, was.Scope, was.ID, was.Kind, "the store holds the image again"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, f := range now {
|
||||
if was, ok := said[k]; ok && was.Summary == f.Summary {
|
||||
continue // written only when what it says changes
|
||||
}
|
||||
if err := h.Findings.Write(ctx, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Findings is the module's held finding writer (views.HeldWriter): the holder writes and clears through it and reads what it
|
||||
// holds (design 47 G §689, A-29, A-38).
|
||||
type Findings interface {
|
||||
Write(ctx context.Context, finding meshconditions.Finding) error
|
||||
Clear(ctx context.Context, scope, id, kind, why string) error
|
||||
Held() []meshconditions.Finding
|
||||
}
|
||||
|
||||
// observed is S25's findings now by key, nil without a listing.
|
||||
func (h *Holder) observed(ctx context.Context) (map[string]meshconditions.Finding, error) {
|
||||
j, err := h.judge(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if j.view == nil {
|
||||
return nil, nil
|
||||
}
|
||||
address := ""
|
||||
if d, err := h.Door(); err == nil {
|
||||
address = d.Address()
|
||||
@@ -870,7 +915,7 @@ func (h *Holder) Observe(ctx context.Context) error {
|
||||
for _, b := range j.built {
|
||||
builds[b.Module+" "+b.FP] = b
|
||||
}
|
||||
now := map[string]meshconditions.FindingsByKeyValue{}
|
||||
now := map[string]meshconditions.Finding{}
|
||||
for _, w := range j.wants {
|
||||
b, ok := builds[w.Module+" "+w.FP]
|
||||
if !ok {
|
||||
@@ -895,63 +940,26 @@ func (h *Holder) Observe(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
f := imageMissing(w.Node, w.Module, reference, h.now())
|
||||
now[f.Key] = f
|
||||
now[views.FindingKey(f.Scope, f.ID, f.Kind)] = f
|
||||
break
|
||||
}
|
||||
}
|
||||
keys, err := h.Bus.Keys(FindingsState)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the findings: %w", err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k == sweepFindingKey {
|
||||
continue // the sweep's own finding, which the sweep clears
|
||||
}
|
||||
if _, still := now[k]; !still {
|
||||
// A finding of a first view unanswered is the reader's (views.Reader), cleared by its answer, not by a sweep.
|
||||
if raw, found, err := h.Bus.Get(FindingsState, k); err == nil && found {
|
||||
if f, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && f.Kind == views.UnansweredKind {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := h.Bus.Delete(FindingsState, k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, f := range now {
|
||||
raw, found, err := h.Bus.Get(FindingsState, k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if was, err := meshconditions.DecodeFindingsByKeyValue(raw); err == nil && was.Summary == f.Summary {
|
||||
continue // written only when what it says changes
|
||||
}
|
||||
}
|
||||
if err := meshconditionsclient.CheckFinding(f); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.Bus.Put(FindingsState, k, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return now, nil
|
||||
}
|
||||
|
||||
// imageMissing is S25's finding, in the controller's words (cmd/mesh-controller/image_missing.go watchMissingImages).
|
||||
func imageMissing(node, module, image string, at time.Time) meshconditions.FindingsByKeyValue {
|
||||
id := module + "." + node
|
||||
return meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: meshconditionsclient.Key(meshconditionsclient.ScopeModule, id, ImageMissing), Kind: ImageMissing,
|
||||
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: id, Machine: node},
|
||||
Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator,
|
||||
// imageMissing is S25's finding, in the controller's words (cmd/mesh-controller/image_missing.go watchMissingImages); its id
|
||||
// is the module's and the machine's, each escaped (design 47 G A-14).
|
||||
func imageMissing(node, module, image string, at time.Time) meshconditions.Finding {
|
||||
id := keys.EscapeName(module) + "." + keys.EscapeName(node)
|
||||
return meshconditions.Finding{Scope: "module", ID: id, Kind: ImageMissing,
|
||||
Subject: meshconditions.Subject{Scope: "module", ID: id, Machine: node},
|
||||
Severity: meshconditions.FindingSeverityWarning, Resolver: "operator",
|
||||
Summary: fmt.Sprintf("%s is left out of every send to %s: its declaration names an image that the artifact store does "+
|
||||
"not hold (the evidence names it); the running container is untouched", module, node),
|
||||
Headline: capital(module) + " is not updated on " + node,
|
||||
Explanation: fmt.Sprintf("The new version of %s names a container image that the mesh's store does not hold, so it "+
|
||||
"was not sent to %s. What runs there is untouched, and the rest of %s is sent as usual.", module, node, node),
|
||||
Needs: fmt.Sprintf("build %s again; the details name the image.", module),
|
||||
Resolved: "Resolved: the store holds the image of " + module + " again",
|
||||
Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: "image " + image + " is not in the store"}},
|
||||
Source: "S25", Observer: Module, At: at.UTC()}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ func TestTheSeatSweepsAtABuildsEndOnARealBusWithNoLease(t *testing.T) {
|
||||
}
|
||||
return m.Data, nil
|
||||
}
|
||||
h := &Holder{Bus: findingsAt{realBus: bus}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf}
|
||||
h := &Holder{Bus: findingsAt{realBus: bus}, Findings: findingsThrough{findingsAt{realBus: bus}}, Door: mem.Door, Listed: mem.Listed, Seats: ask, Now: time.Now, Logf: t.Logf}
|
||||
h.Acts = meshhandactsclient.Recorder{Ask: ask}
|
||||
|
||||
// A build's end heard from the builds bucket, as follow hands it over.
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
builds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -17,14 +17,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry"
|
||||
)
|
||||
|
||||
// memBus is the holder's bus in memory: states by name, each key's history, and what was published.
|
||||
@@ -229,7 +229,7 @@ func aMesh(t *testing.T) (*Holder, *memBus, *memDoor, *[]string) {
|
||||
want, _ := json.Marshal(meshwants.WantsByWantNodeModuleValue{V: 1, Fp: fp(1), Self: true})
|
||||
_ = b.Put(WantsRead, "want.ace.letta", json.RawMessage(want))
|
||||
var acts []string
|
||||
h := &Holder{Bus: b, Door: func() (Door, error) { return door, nil }, Seats: door.seat,
|
||||
h := &Holder{Bus: b, Findings: findingsThrough{b}, Door: func() (Door, error) { return door, nil }, Seats: door.seat,
|
||||
Listed: func(context.Context) (*View, error) { return &View{L: listing}, nil },
|
||||
Now: func() time.Time { return day.Add(48 * time.Hour) },
|
||||
Acts: meshhandactsclient.Recorder{Ask: func(key string, body any) (json.RawMessage, error) {
|
||||
@@ -370,7 +370,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
|
||||
if c.letGo[0] == archiveRef("letta", 2) && !slices.Contains(door.calls, "seat:oci-artifact-registry.remove letta/files@"+digestOf(archiveRef("letta", 2))+" "+Retention) {
|
||||
t.Errorf("the seats were called %v", door.calls)
|
||||
}
|
||||
_, finding, _ := b.Get(FindingsState, sweepFindingKey)
|
||||
_, finding, _ := b.Get(FindingsState, views.FindingKey("module", Module, SweepRefused))
|
||||
if finding != (c.refuse != "") {
|
||||
t.Errorf("the sweep's finding is written: %v, want %v", finding, c.refuse != "")
|
||||
}
|
||||
@@ -379,7 +379,7 @@ func TestTheSeatKeepsTheFiveNewestAndTheWantedAndSweepsWithNoLease(t *testing.T)
|
||||
if _, err := h.Sweep(context.Background(), Sweeping{Most: MostPerSweep, By: "again"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, still, _ := b.Get(FindingsState, sweepFindingKey); still {
|
||||
if _, still, _ := b.Get(FindingsState, views.FindingKey("module", Module, SweepRefused)); still {
|
||||
t.Error("a clean sweep left the sweep's finding")
|
||||
}
|
||||
return
|
||||
@@ -410,13 +410,16 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi
|
||||
if err := h.Observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "letta.ace", ImageMissing)
|
||||
key := views.FindingKey("module", "letta.ace", ImageMissing)
|
||||
raw, found, _ := b.Get(FindingsState, key)
|
||||
f, err := meshconditions.DecodeFindingsByKeyValue(raw)
|
||||
var f meshconditions.Finding
|
||||
err := json.Unmarshal(raw, &f)
|
||||
if !found || err != nil || f.Subject.Machine != "ace" || f.Source != "S25" || !strings.Contains(f.Evidence[0].Said, imageRef("letta", 1)) {
|
||||
t.Fatalf("the finding is %+v (%v, %v)", f, found, err)
|
||||
}
|
||||
view.L.Repos[repo].Revisions[digest] = true
|
||||
// A restart: a holder made afresh holds no finding of its own, and clears what the writer holds (design 47 G A-32, A-38).
|
||||
h = &Holder{Bus: h.Bus, Findings: h.Findings, Door: h.Door, Listed: h.Listed, Seats: h.Seats, Now: h.Now, Acts: h.Acts, Logf: h.Logf}
|
||||
if err := h.Observe(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -441,3 +444,41 @@ func TestAWantedImageTheStoreLacksIsAFindingAndImagesNamesWantsAndHaves(t *testi
|
||||
t.Fatalf("the images of ace are %+v: %v", images, err)
|
||||
}
|
||||
}
|
||||
|
||||
// FindingsState is the module's state `findings`, where the tests' writer keeps what the holder writes through it.
|
||||
const FindingsState = "findings"
|
||||
|
||||
// findingsThrough is the holder's finding writer in a test: it keeps a finding in its key and deletes it as the module's
|
||||
// writer does (views.Writer), without the bus.
|
||||
type findingsThrough struct {
|
||||
bus interface {
|
||||
Put(state, key string, value any) error
|
||||
Delete(state, key string) error
|
||||
}
|
||||
}
|
||||
|
||||
func (f findingsThrough) Write(_ context.Context, finding meshconditions.Finding) error {
|
||||
return f.bus.Put(FindingsState, views.FindingKey(finding.Scope, finding.ID, finding.Kind), finding)
|
||||
}
|
||||
|
||||
// Held is what the writer holds: the findings in the module's state.
|
||||
func (f findingsThrough) Held() []meshconditions.Finding {
|
||||
var out []meshconditions.Finding
|
||||
keys, _ := f.bus.(interface {
|
||||
Keys(string) ([]string, error)
|
||||
}).Keys(FindingsState)
|
||||
for _, k := range keys {
|
||||
raw, ok, _ := f.bus.(interface {
|
||||
Get(string, string) ([]byte, bool, error)
|
||||
}).Get(FindingsState, k)
|
||||
var m meshconditions.Finding
|
||||
if ok && json.Unmarshal(raw, &m) == nil {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f findingsThrough) Clear(_ context.Context, scope, id, kind, _ string) error {
|
||||
return f.bus.Delete(FindingsState, views.FindingKey(scope, id, kind))
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
|
||||
ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry"
|
||||
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
)
|
||||
|
||||
// Repo is one repository as the seats' list answers it.
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
@@ -24,12 +24,29 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
func main() {
|
||||
s := &Store{Ask: stdio.Ask}
|
||||
v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")}, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}}
|
||||
v := &views.Reader{Module: "artifact-store-tools", Logf: logf, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}}
|
||||
v.Follow(assignerModule, "wants", WantsTool)
|
||||
v.Follow(indexerModule, "manifests", ManifestsTool)
|
||||
h := &Holder{Bus: runtimeBus{views: v}, Door: DoorAt(os.Getenv("MESH_ARTIFACT_STORE_ADDRESS_FILE"), s), Listed: s.View,
|
||||
Seats: stdio.Ask, Now: time.Now, Logf: logf}
|
||||
h.Acts = meshhandactsclient.Recorder{Ask: stdio.Ask}
|
||||
// The module's one held writer (design 47 G §689, A-16, A-29, A-32): the holder's findings and the reader's go through it.
|
||||
w, err := views.NewStdioHeldWriter(views.WriterConfig[meshconditions.Finding, meshconditions.FindingCleared, meshconditions.FindingsStated, meshconditions.FindingsAskedEvent]{
|
||||
Asked: meshconditions.SubjectFindingsAsked, Module: Module, State: stdio.State("findings"), Kinds: []string{ImageMissing, SweepRefused}})
|
||||
if err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
h.Findings = w
|
||||
v.Findings = w
|
||||
// the runtime answers the writer's subscription only once Serve has its channel
|
||||
go func() {
|
||||
<-stdio.Served()
|
||||
if err := w.Start(context.Background()); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
v.Start(context.Background())
|
||||
go follow(h, v)
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
|
||||
sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
sweep "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
sweep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
@@ -474,14 +474,19 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
t.Errorf("invokes %v", m.Invokes)
|
||||
}
|
||||
// ADR 0615: the assigner's wants and the indexer's manifests are asked once and followed by their events, not read.
|
||||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests"}) ||
|
||||
if !reflect.DeepEqual(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-indexer.views.manifests", "mesh-conditions.findings-asked"}) ||
|
||||
slices.Contains(m.Reads, WantsRead) || slices.Contains(m.Reads, ManifestsRead) {
|
||||
t.Errorf("consumes %v, reads %v", m.Consumes, m.Reads)
|
||||
}
|
||||
// The operator, 2026-11-04 (issue 1966): it reaches the store through the registry seats' verbs, and requires no
|
||||
// provision artifact-store.
|
||||
if len(m.Requires) != 0 {
|
||||
t.Errorf("requires %v, want nothing: the store is reached through the registry seats", m.Requires)
|
||||
// What it requires is its finding state and stream alone, in their template form (design 47 G §529).
|
||||
var required []string
|
||||
for _, r := range m.Requires {
|
||||
required = append(required, fmt.Sprint(r.(map[string]any)["name"]))
|
||||
}
|
||||
if !reflect.DeepEqual(required, []string{"findings", "finding-events"}) {
|
||||
t.Errorf("requires %v, want its findings and finding-events alone: the store is reached through the registry seats", m.Requires)
|
||||
}
|
||||
// Its address file is filled from the image seat holder's listen (ADR 0632 §1), never by the retired provision.
|
||||
address, err := os.ReadFile("../../files/store-address")
|
||||
|
||||
@@ -10,25 +10,23 @@ package main
|
||||
// sweep's finding, sweep-refused; the next sweep with no refusal clears it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient/reference"
|
||||
ociseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry"
|
||||
artifactref "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient/reference"
|
||||
ociseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
|
||||
reg "git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry"
|
||||
reg "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry"
|
||||
)
|
||||
|
||||
// SweepRefused is the sweep's finding: a seat refused a hold or a remove the sweep asked.
|
||||
const SweepRefused = "sweep-refused"
|
||||
|
||||
// sweepFindingKey is the one key of the sweep's finding in the module's findings.
|
||||
var sweepFindingKey = meshconditionsclient.Key(meshconditionsclient.ScopeModule, Module, SweepRefused)
|
||||
|
||||
// canonical is a kept reference in the older form, `artifact-store://<path>`, whichever form it came in; another is as
|
||||
// it was.
|
||||
func canonical(reference string) string {
|
||||
@@ -115,26 +113,28 @@ func (h *Holder) removeOnSeat(reference, why string) error {
|
||||
// sweepFinding writes the sweep's finding: what the seat refused, and why.
|
||||
func (h *Holder) sweepFinding(what string, err error) {
|
||||
at := h.now().UTC()
|
||||
f := meshconditions.FindingsByKeyValue{V: meshconditionsclient.FindingVersion, Key: sweepFindingKey, Kind: SweepRefused,
|
||||
Subject: meshconditions.Subject{Scope: meshconditionsclient.ScopeModule, ID: Module},
|
||||
Severity: meshconditionsclient.Warning, Resolver: meshconditionsclient.ResolverOperator,
|
||||
f := meshconditions.Finding{Scope: "module", ID: keys.EscapeName(Module), Kind: SweepRefused,
|
||||
Subject: meshconditions.Subject{Scope: "module", ID: keys.EscapeName(Module)},
|
||||
Severity: meshconditions.FindingSeverityWarning, Resolver: "operator",
|
||||
Summary: fmt.Sprintf("the artifact sweep stopped: a registry seat refused %s (the evidence names why)", what),
|
||||
Headline: "The artifact sweep stopped",
|
||||
Explanation: "The sweep keeps what the mesh still needs and removes the rest through the registry seats. A seat " +
|
||||
"refused one of its asks, so it stopped before anything more was removed; what it kept stays kept.",
|
||||
Needs: "read the seat holder's journal for the refusal; the next sweep asks again.",
|
||||
Resolved: "Resolved: the artifact sweep ran through without a refusal",
|
||||
Evidence: []meshconditions.Evidence{{At: at.UTC(), Said: fmt.Sprintf("%s: %v", what, err)}},
|
||||
Source: "CAT-AS1", Observer: Module, At: at}
|
||||
if werr := h.Bus.Put(FindingsState, sweepFindingKey, f); werr != nil {
|
||||
if werr := h.Findings.Write(context.Background(), f); werr != nil {
|
||||
h.logf("the sweep's finding (%s: %v) could not be written: %v", what, err, werr)
|
||||
}
|
||||
}
|
||||
|
||||
// sweepClean clears the sweep's finding after a sweep no seat refused.
|
||||
// sweepClean clears the sweep's finding, when the held writer holds it, after a sweep no seat refused.
|
||||
func (h *Holder) sweepClean() {
|
||||
if _, found, err := h.Bus.Get(FindingsState, sweepFindingKey); err == nil && found {
|
||||
if err := h.Bus.Delete(FindingsState, sweepFindingKey); err != nil {
|
||||
for _, was := range h.Findings.Held() {
|
||||
if was.Kind != SweepRefused {
|
||||
continue
|
||||
}
|
||||
if err := h.Findings.Clear(context.Background(), was.Scope, was.ID, was.Kind, "a sweep ran through without a refusal"); err != nil {
|
||||
h.logf("the sweep's finding could not be cleared: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The builder's keys are read through its published package, taken by its series (ADR 0634 §1).
|
||||
if m.Packages["builds"] != "0.1" {
|
||||
if m.Packages["git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"] != "0.1" {
|
||||
t.Errorf("packages %v: want builds of series 0.1", m.Packages)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
|
||||
@@ -5,18 +5,17 @@ go 1.26.0
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshartifactsweep v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
@@ -35,4 +34,6 @@ require (
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
)
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect
|
||||
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 // indirect
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"module": "artifact-store-tools",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"packages": {
|
||||
"builds": "0.1"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds": "0.1"
|
||||
},
|
||||
"claims": [
|
||||
{
|
||||
@@ -39,10 +39,8 @@
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-delivery-assigner.views.wants",
|
||||
"mesh-delivery-indexer.views.manifests"
|
||||
],
|
||||
"state": [
|
||||
"findings"
|
||||
"mesh-delivery-indexer.views.manifests",
|
||||
"mesh-conditions.findings-asked"
|
||||
],
|
||||
"tools": [
|
||||
"artifact_store_repositories",
|
||||
@@ -108,5 +106,26 @@
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
"name": "findings",
|
||||
"storage": "key-value",
|
||||
"template": "state"
|
||||
},
|
||||
{
|
||||
"name": "finding-events",
|
||||
"storage": "stream",
|
||||
"template": "events",
|
||||
"options": {
|
||||
"events": [
|
||||
"finding-written.*",
|
||||
"finding-cleared.*",
|
||||
"findings-stated.*"
|
||||
],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "asus-zephyrus-g14",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"moved": true,
|
||||
"templates": "templates",
|
||||
"capabilities": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "audit-logger",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"slug": "audit",
|
||||
"consumes": [
|
||||
"**"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "avahi",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "baserow",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "blueman",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "bluetooth",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "brother",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"requires": [
|
||||
{
|
||||
"name": "s3-bucket",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "buildah",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"user-namespaces"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "ca-trust",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"slug": "catrust",
|
||||
"capabilities": [
|
||||
"service-manager"
|
||||
|
||||
@@ -78,12 +78,12 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
||||
|
||||
| what | how |
|
||||
|---|---|
|
||||
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206) |
|
||||
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206), and said whole as the event `holdings-changed` at start, on every change and again when the licence manager says `holdings.asked` (design 39 bus-and-switch §23) |
|
||||
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
||||
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
|
||||
| what this node should hold | the licence manager's `binding.changed` naming this node, and at start (or a reconnect) its first view, `anthropic-licence-manager.bindings` with `consumer` this node; an event for another node is ignored. On a newer generation, at start, and on the vault's `secret-changed` for the licence it holds, this module asks `anthropic-licence-manager.current` which licence, and the vault's `get` for that licence's access token (`licence.<key>.access`), sealed to its own key, and writes it access-token-only — so the agent here never refreshes, and never receives a refresh token. A home holding no token is fetched again at the same generation (design 39 bus-and-switch §19, §22) |
|
||||
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
||||
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
||||
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
||||
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question |
|
||||
| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `<kind>.<project and path, base64url>`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text |
|
||||
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
|
||||
|
||||
@@ -150,8 +150,8 @@ record is how a habit that found a way round is seen.
|
||||
|
||||
## Tools
|
||||
|
||||
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull`, `claude_code_grant` (for the licence
|
||||
manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
|
||||
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull` (asks the vault for this node's
|
||||
licence token now), `claude_code_grant` (for the licence manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
|
||||
more).
|
||||
|
||||
**What a session runs or obeys is the operator's, at the controller's terminal** (hq issue 340). A hook, a
|
||||
@@ -233,11 +233,8 @@ key says waiting, chosen (the option, who answered, on which channel, when), end
|
||||
refused, cancelled, replaced), or not proven: the answer is read from the router's own record and held to the
|
||||
question as `questions` keeps it, so a question rewritten after it was asked, a warrant for another ask or a choice
|
||||
after expiry is never read as a choice. One call waits at most 60 s; the tool asks a session to read a question at
|
||||
most once a minute, and nothing enforces it. At most two questions wait at once, mesh-wide: each holds one of two
|
||||
slot keys in `questions`, taken by create or compare-and-set, so two nodes asking at once cannot both take the last,
|
||||
and a slot that cannot be read refuses the question. Questions alone never fill the router's three open asks, so one
|
||||
is left for a proposed instruction section while two are open. A question the router holds no record of, its refusal
|
||||
not heard here, keeps its slot and reads as waiting until it expires: the router may still take it from its queue,
|
||||
most once a minute, and nothing enforces it. No question is refused for how many wait: the router offers an asker at most three asks at once, holds the oldest back behind newer ones and offers it again when one ends (hq ADR 0666 §12). A question the router holds no record of, its refusal
|
||||
not heard here, reads as waiting until it expires: the router may still take it from its queue,
|
||||
and no cancel can overtake it there (the queue offers work again after an error, and a cancel of an ask not yet
|
||||
recorded is dropped); once expired, the router refuses it if taken late. A refused question keeps none of
|
||||
its words. An answer is the operator's word to the session and authorises nothing; a question never defaults.
|
||||
|
||||
@@ -457,13 +457,9 @@ func TestTheGuardIsAskedAboutEveryToolAndRefusesEachForAnotherAccount(t *testing
|
||||
func TestAMoveWritesTheAccountFileBesideTheToken(t *testing.T) {
|
||||
p, w := node(t, "anchor")
|
||||
writeFile(t, p.binding(), `{"licence":"personal","kind":"subscription","generation":4}`)
|
||||
ask := func(address string, args any) (json.RawMessage, error) {
|
||||
key := args.(map[string]any)["public_key"].(string)
|
||||
g, _ := json.Marshal(Grant{AccessToken: "at", ExpiresAt: now + 3_600_000})
|
||||
box, _ := Seal(string(g), key)
|
||||
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 4, Sealed: &box,
|
||||
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}})
|
||||
}
|
||||
var asked []string
|
||||
ask := vaulted(t, Current{Licence: "personal", Key: "personal", Kind: "subscription", Generation: 4,
|
||||
Identity: &Identity{AccountUUID: "u-1", EmailAddress: "operator@example.org"}}, "at", &asked)
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 4}, ask, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
// claude-code's reading of which modules exist and of each placed module's manifest (design 47 G §196, §594, §614): the
|
||||
// catalog half, taken through meshcatalogclient's follower of the watched branches on the part's one views reader. A
|
||||
// module's manifest is the one its catalog graph entry carries whole (§443), never the indexer's state `modules` or
|
||||
// `manifests`.
|
||||
|
||||
import (
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
)
|
||||
|
||||
// CatalogHalf is what this module reads of the catalog half.
|
||||
type CatalogHalf interface {
|
||||
// Entry is a module's entry at the trunk; an error while a watched branch has no first view yet, never "no module".
|
||||
Entry(module string) (catalog.CatalogEntry, bool, error)
|
||||
// Ready is nil once every watched branch has its first view (none-yet counting as one), else says what it waits for.
|
||||
Ready() error
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// wiringOf is a part's reader in miniature: it keeps the followers adopted, so a test starts them as the reader's Start does.
|
||||
type wiringOf struct {
|
||||
mu sync.Mutex
|
||||
fs []*views.Follower
|
||||
}
|
||||
|
||||
func (w *wiringOf) Adopt(f *views.Follower, _, _ string, _ func(context.Context, []byte) error) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.fs = append(w.fs, f)
|
||||
return nil
|
||||
}
|
||||
func (w *wiringOf) Drop(string, string) error { return nil }
|
||||
|
||||
// Design 47 G §196, §594: which modules exist, and the manifest of each, are the catalog half's; a branch with no first
|
||||
// view yet is an error naming it, never no module, and an entry's manifest is the one it carries whole.
|
||||
func TestCatalogHalfIsTheWatchedBranchesNewestRevision(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
answered := false
|
||||
var mu sync.Mutex
|
||||
call := func(key string, _ any) (json.RawMessage, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch key {
|
||||
case meshcatalogclient.RegistrationsVerb:
|
||||
return json.Marshal(catalog.RegistrationsAnswer{Registrations: []catalog.Registration{{Module: "m", Version: 1, Branches: []string{"main"}}}})
|
||||
case meshcatalogclient.SourceVerb:
|
||||
return json.Marshal(delivery.SourceAnswer{})
|
||||
case meshcatalogclient.RevisionVerb:
|
||||
if !answered {
|
||||
return nil, fmt.Errorf("the holder is away")
|
||||
}
|
||||
return json.Marshal(catalog.RevisionAnswer{Revision: &catalog.CatalogRevision{Revision: 1, Kind: meshcatalogclient.KindTrunk},
|
||||
Entries: []catalog.CatalogEntry{{Module: "pg", Manifest: map[string]any{"module": "pg"}}}})
|
||||
}
|
||||
return nil, fmt.Errorf("asked %s", key)
|
||||
}
|
||||
w := &wiringOf{}
|
||||
b, err := meshcatalogclient.NewBranches(w, call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := b
|
||||
if _, _, err := h.Entry("pg"); err == nil || !strings.Contains(err.Error(), "no watched branch") {
|
||||
t.Fatalf("before the registrations: %v, want an error naming the missing branch", err)
|
||||
}
|
||||
go func() { _ = w.fs[0].Start(ctx) }() // the registrations
|
||||
for len(b.Followed()) == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("the registrations' branch is never followed")
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
if _, _, err := h.Entry("pg"); err == nil || !strings.Contains(err.Error(), "no first view of main") {
|
||||
t.Fatalf("with no first view: %v, want an error naming the branch, never no module", err)
|
||||
}
|
||||
mu.Lock()
|
||||
answered = true
|
||||
mu.Unlock()
|
||||
c, _ := b.Catalog("main")
|
||||
if err := c.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, err := h.Entry("pg")
|
||||
if err != nil || !ok || got.Manifest["module"] != "pg" || h.Ready() != nil {
|
||||
t.Fatalf("with the first view: %v, %v, %v", got, ok, err)
|
||||
}
|
||||
if _, ok, err := h.Entry("absent"); ok || err != nil {
|
||||
t.Fatalf("a module the half does not list: %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
var now = time.Now().UnixMilli()
|
||||
@@ -282,31 +286,52 @@ func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// seat answers `current` as the manager does: the grant sealed to the key the node sent.
|
||||
// seat answers `current` as the manager does, the licence and its key and never a token, and `get` as the vault does:
|
||||
// the licence's access secret sealed to the key the node sent (design 39 bus-and-switch §18, §19).
|
||||
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
|
||||
return vaulted(t, Current{Licence: licence, Key: licence, Kind: "subscription", Generation: gen}, token, asked)
|
||||
}
|
||||
|
||||
// vaulted answers `current` with c and the vault's `get` of c's access secret with token.
|
||||
func vaulted(t *testing.T, c Current, token string, asked *[]string) Ask {
|
||||
return func(address string, args any) (json.RawMessage, error) {
|
||||
*asked = append(*asked, address)
|
||||
key := args.(map[string]any)["public_key"].(string)
|
||||
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000})
|
||||
box, err := Seal(string(g), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
a := args.(map[string]any)
|
||||
switch address {
|
||||
case SeatVerb("current"):
|
||||
if _, sent := a["public_key"]; sent || a["consumer"] == nil {
|
||||
t.Fatalf("current asked with %v", a)
|
||||
}
|
||||
return json.Marshal(c)
|
||||
case VaultGet:
|
||||
if a["name"] != AccessSecret(c.Key) {
|
||||
t.Fatalf("the vault asked for %v", a["name"])
|
||||
}
|
||||
// Each answer a little later, as each rotation hands a later token.
|
||||
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000 + int64(len(*asked))*1000})
|
||||
box, err := Seal(string(g), a["public_key"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return json.Marshal(map[string]any{"name": a["name"], "generation": 1, "sealed": box})
|
||||
}
|
||||
return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box})
|
||||
t.Fatalf("asked %s", address)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
||||
// design 39 bus-and-switch §19: a newer generation asks the seat which licence, and the vault for its token.
|
||||
func TestANewerGenerationFetchesTheTokenOnceFromTheVault(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
var asked []string
|
||||
ask := seat(t, "personal", "at-1", 3, &asked)
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" {
|
||||
if len(asked) != 2 || asked[0] != SeatVerb("current") || asked[1] != VaultGet || creds(t, p)["accessToken"] != "at-1" {
|
||||
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
||||
}
|
||||
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 {
|
||||
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 2 {
|
||||
t.Fatal("an equal generation asked again")
|
||||
}
|
||||
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
|
||||
@@ -497,30 +522,104 @@ func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A manager whose store was rebuilt counts generations from one again (novox/hq issue 243): a binding with a
|
||||
// lower generation than the one applied is still a binding to apply, and only the one applied is skipped.
|
||||
func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
|
||||
// design 39 bus-and-switch §19, B11: only a newer generation than the one applied is fetched; a lower one is not, and
|
||||
// the vault's secret-changed for the licence held asks its token again, for another secret nothing.
|
||||
func TestTheTokenIsAskedOnANewerGenerationAndOnTheVaultsChange(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
var asked []string
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 16},
|
||||
seat(t, "personal", "at-old", 16, &asked), writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The rotation that came with it: a later token, as a refresh hands one over.
|
||||
later := func(address string, args any) (json.RawMessage, error) {
|
||||
asked = append(asked, address)
|
||||
g, _ := json.Marshal(Grant{AccessToken: "at-new", ExpiresAt: now + 7_200_000})
|
||||
box, err := Seal(string(g), args.(map[string]any)["public_key"].(string))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return json.Marshal(Current{Licence: "personal", Kind: "subscription", Generation: 3, Sealed: &box})
|
||||
if done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
|
||||
seat(t, "personal", "at-low", 3, &asked), writer(w)); err != nil || done != "" || len(asked) != 2 {
|
||||
t.Fatalf("a lower generation was fetched: %q %v, asked %v", done, err, asked)
|
||||
}
|
||||
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3},
|
||||
later, writer(w)); err != nil {
|
||||
rotated := seat(t, "personal", "at-new", 16, &asked)
|
||||
for _, other := range []string{`{"name":"licence.personal.grant","generation":5}`, `{"name":"licence.other.access","generation":5}`} {
|
||||
if done, err := OnSecretChanged(p, []byte(other), rotated, writer(w)); err != nil || done != "" || len(asked) != 2 {
|
||||
t.Fatalf("%s asked again: %q %v, asked %v", other, done, err, asked)
|
||||
}
|
||||
}
|
||||
if _, err := OnSecretChanged(p, []byte(`{"name":"licence.personal.access","generation":5,"by":"x","at":"2026-11-11T00:00:00Z"}`),
|
||||
rotated, writer(w)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(asked) != 2 || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 3 {
|
||||
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
|
||||
if len(asked) != 4 || asked[3] != VaultGet || creds(t, p)["accessToken"] != "at-new" || HoldingsOf(p).Generation != 16 {
|
||||
t.Fatalf("asked %v, credentials %v: the vault's change was not applied", asked, creds(t, p))
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §22/AC1, B22: binding.changed, decoded from a copy of the manager's form, is acted on only
|
||||
// when it names this node's consumer; an event for another consumer is ignored.
|
||||
func TestAnEventForAnotherConsumerIsIgnored(t *testing.T) {
|
||||
p, w := node(t, "server")
|
||||
var asked []string
|
||||
ask := seat(t, "api", "at-3", 3, &asked)
|
||||
said, err := os.ReadFile("testdata/binding-changed.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || done != "" || len(asked) != 0 {
|
||||
t.Fatalf("an event for laptop was acted on by server: %q %v, asked %v", done, err, asked)
|
||||
}
|
||||
p, w = node(t, "laptop")
|
||||
if done, err := OnBindingChanged(p, said, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":3`) || len(asked) != 2 {
|
||||
t.Fatalf("laptop's own event: %q %v, asked %v", done, err, asked)
|
||||
}
|
||||
if done, _ := OnBindingChanged(p, []byte(`{"consumer":"laptop","licence":"","generation":3,"from":"api"}`), ask, writer(w)); !strings.Contains(done, "released") {
|
||||
t.Fatalf("a release said %q", done)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §22, B10: at its start the module asks the seat's bindings for its own consumer and applies
|
||||
// that first view; its manifest consumes binding.changed and no longer reads the manager's state.
|
||||
func TestTheFirstViewOfTheBindingIsApplied(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
var asked []string
|
||||
current := vaulted(t, Current{Licence: "api", Key: "api", Kind: "subscription", Generation: 9}, "at-9", &asked)
|
||||
ask := func(address string, args any) (json.RawMessage, error) {
|
||||
if address == SeatVerb("bindings") {
|
||||
asked = append(asked, address)
|
||||
if args.(map[string]any)["consumer"] != "laptop" {
|
||||
t.Fatalf("bindings asked with %v", args)
|
||||
}
|
||||
return json.RawMessage(`[{"consumer":"laptop","licence":"api","key":"api","kind":"api-key","generation":9,"why":"carried over","by":"claude-licence-manager"}]`), nil
|
||||
}
|
||||
return current(address, args)
|
||||
}
|
||||
if done, err := FirstBinding(p, ask, writer(w)); err != nil || !strings.Contains(done, `"generation":9`) {
|
||||
t.Fatalf("the first view: %q %v, asked %v", done, err, asked)
|
||||
}
|
||||
if len(asked) != 3 || asked[0] != SeatVerb("bindings") || creds(t, p)["accessToken"] != "at-9" {
|
||||
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
|
||||
}
|
||||
// §19: at every start the vault is asked, though the binding is the one applied.
|
||||
if _, err := FirstBinding(p, ask, writer(w)); err != nil || len(asked) != 6 || asked[5] != VaultGet {
|
||||
t.Fatalf("a start asked %v", asked)
|
||||
}
|
||||
// A-134: the first view is the follower the module's views reader adopts on binding.changed.<consumer>; its answer
|
||||
// applies the binding as FirstBinding does.
|
||||
r := &views.Reader{Module: Module}
|
||||
f := bindingsFollower(p, ask, writer(w), func(done string, err error) {
|
||||
if err != nil || !strings.Contains(done, `"generation":9`) {
|
||||
t.Errorf("the adopted first view applied %q, %v", done, err)
|
||||
}
|
||||
})
|
||||
if err := r.Adopt(f, Manager, BindingChangedEvent, func(context.Context, []byte) error { return nil }); err != nil || !r.Follows(Manager+".bindings") {
|
||||
t.Fatalf("the bindings follower is not adopted: %v", err)
|
||||
}
|
||||
if err := f.Start(context.Background()); err != nil || len(asked) != 9 {
|
||||
t.Fatalf("the adopted first view: %v, asked %v", err, asked)
|
||||
}
|
||||
raw, _ := os.ReadFile("../../module.json")
|
||||
var m struct {
|
||||
Consumes, Invokes []string
|
||||
Reads []string
|
||||
}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if !slices.Contains(m.Consumes, BindingChanged) || !slices.Contains(m.Consumes, SecretChanged) ||
|
||||
!slices.Contains(m.Invokes, SeatVerb("bindings")) || !slices.Contains(m.Invokes, VaultGet) || len(m.Reads) != 0 {
|
||||
t.Fatalf("the manifest consumes %v, invokes %v, reads %v", m.Consumes, m.Invokes, m.Reads)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,10 +276,12 @@ func TestTheManifestListsEveryToolServed(t *testing.T) {
|
||||
}
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
// The hand-act seat's warranted and hand-act, the seat mesh-catalog's seat list (ADR 0635 §1), and the owners' read tools of the three followed states; no verb of
|
||||
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed.
|
||||
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) ||
|
||||
!slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) ||
|
||||
!slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) {
|
||||
// the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. The
|
||||
// licence binding is the seat's bindings and the manager's binding.changed, its token the vault's get and secret-changed (design 39 bus-and-switch §19, §22, B9); a
|
||||
// notice goes out on the operator channel's notify (design 47 G GR8).
|
||||
if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.seat-definitions", RevisionVerb, RegistrationsVerb, SourceVerb, AssignmentsTool, SeatVerb("bindings"), SeatVerb("current"), VaultGet, "seat:operator-channel.notify"}) ||
|
||||
len(m.Reads) != 0 ||
|
||||
!slices.Equal(m.Consumes, []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", HoldingsAsked, BindingChanged, SecretChanged}) {
|
||||
t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes)
|
||||
}
|
||||
if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) ||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// What this node holds, said (design 39 bus-and-switch §23): kept in this module's own state `holdings` and said as
|
||||
// the event `holdings-changed`, the holdings report whole and never a token, at start, whenever it changes, and again
|
||||
// when the licence manager says `holdings.asked`.
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// HoldingsChanged is the event that says what a node holds.
|
||||
const HoldingsChanged = "holdings-changed"
|
||||
|
||||
// HoldingsAsked is the licence manager's event asking every node to say what it holds.
|
||||
const HoldingsAsked = Manager + ".holdings.asked"
|
||||
|
||||
// HoldingsSayer keeps and says what this node holds.
|
||||
type HoldingsSayer struct {
|
||||
Put func(Holdings) error
|
||||
Emit func(Holdings) error
|
||||
said string
|
||||
}
|
||||
|
||||
// Say keeps and says now when it differs from what was said last, or always when again: true when it said it. A
|
||||
// failure says nothing was said, so the next call tries again.
|
||||
func (s *HoldingsSayer) Say(now Holdings, again bool) (bool, error) {
|
||||
raw, _ := json.Marshal(now)
|
||||
if !again && string(raw) == s.said {
|
||||
return false, nil
|
||||
}
|
||||
if err := s.Put(now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := s.Emit(now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.said = string(raw)
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// design 39 bus-and-switch §23: the holdings are said at start, on a change and when asked again, kept in the
|
||||
// module's own state each time, the report whole and never a token.
|
||||
func TestHoldingsAreSaidAtStartAndOnChange(t *testing.T) {
|
||||
var kept, said []string
|
||||
failing := false
|
||||
s := &HoldingsSayer{
|
||||
Put: func(h Holdings) error { raw, _ := json.Marshal(h); kept = append(kept, string(raw)); return nil },
|
||||
Emit: func(h Holdings) error {
|
||||
if failing {
|
||||
return errors.New("no responders")
|
||||
}
|
||||
raw, _ := json.Marshal(h)
|
||||
said = append(said, string(raw))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
fp := "sha256:0123456789abcdef"
|
||||
start := Holdings{Node: "laptop"}
|
||||
changed := Holdings{Node: "laptop"}
|
||||
changed.Refresh.Present, changed.Refresh.Fingerprint = true, &fp
|
||||
for i, step := range []struct {
|
||||
h Holdings
|
||||
again, out bool
|
||||
}{{start, false, true}, {start, false, false}, {changed, false, true}, {changed, true, true}} {
|
||||
if got, err := s.Say(step.h, step.again); err != nil || got != step.out {
|
||||
t.Fatalf("step %d: said %v (%v), want %v", i, got, err, step.out)
|
||||
}
|
||||
}
|
||||
if len(said) != 3 || len(kept) != 3 {
|
||||
t.Fatalf("said %d, kept %d, want 3 each", len(said), len(kept))
|
||||
}
|
||||
if !strings.Contains(said[1], fp) || !strings.Contains(said[1], `"node":"laptop"`) {
|
||||
t.Fatalf("the report is not said whole: %s", said[1])
|
||||
}
|
||||
failing = true
|
||||
if got, err := s.Say(start, false); err == nil || got {
|
||||
t.Fatal("a failed event counts as said")
|
||||
}
|
||||
failing = false
|
||||
if got, _ := s.Say(start, false); !got {
|
||||
t.Fatal("what failed to be said is not said at the next call")
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -17,6 +11,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
@@ -57,7 +55,7 @@ func someSeats() []seatdef.Definition {
|
||||
// saying what its tool replaces, as the seat mesh-module-assigner and the indexer keep them on the bus.
|
||||
func aMesh(t *testing.T) aBus {
|
||||
t.Helper()
|
||||
b := aBus{AssignmentsRead: {}, ModulesRead: {}, ManifestsRead: {}}
|
||||
b := aBus{AssignmentsRead: {}}
|
||||
for _, p := range []mesh.Assignments{
|
||||
{V: 1, Node: "anchor", Decided: mesh.NodeDecided{PublicDomain: "Anchor.Example"},
|
||||
Modules: []mesh.PlacedModule{{Module: "docker"}, {Module: "zsh"}}},
|
||||
@@ -71,21 +69,33 @@ func aMesh(t *testing.T) aBus {
|
||||
b[AssignmentsRead][k] = v
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// fakeCatalog is the catalog half as the tests give it: docker and zsh, docker's manifest saying what its tool replaces.
|
||||
type fakeCatalog struct {
|
||||
entries map[string]catalog.CatalogEntry
|
||||
notYet error
|
||||
}
|
||||
|
||||
func (f fakeCatalog) Entry(module string) (catalog.CatalogEntry, bool, error) {
|
||||
e, ok := f.entries[module]
|
||||
return e, ok, f.notYet
|
||||
}
|
||||
|
||||
func (f fakeCatalog) Ready() error { return f.notYet }
|
||||
|
||||
func aCatalog() fakeCatalog {
|
||||
f := fakeCatalog{entries: map[string]catalog.CatalogEntry{}}
|
||||
for module, manifest := range map[string]string{
|
||||
"docker": `{"module":"docker","replaces":{"docker_logs":["docker logs"]}}`,
|
||||
"zsh": `{"module":"zsh"}`,
|
||||
} {
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256([]byte(manifest)))
|
||||
mkey, _ := catalogclient.ManifestsKey(digest)
|
||||
raw, err := json.Marshal(catalog.ManifestsByDigestValue{V: 1, Module: module, Manifest: manifest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b[ManifestsRead][mkey] = raw
|
||||
key, _ := catalogclient.ModulesKey(module)
|
||||
b[ModulesRead][key], _ = json.Marshal(catalog.ModulesByModuleValue{V: 1, None: "nothing is built of it", Manifest: digest, Repository: "novox/mesh-catalog", Commit: "c"})
|
||||
var whole map[string]any
|
||||
_ = json.Unmarshal([]byte(manifest), &whole)
|
||||
f.entries[module] = catalog.CatalogEntry{Module: module, Manifest: whole}
|
||||
}
|
||||
return b
|
||||
return f
|
||||
}
|
||||
|
||||
func resolving(name string) []string {
|
||||
@@ -97,7 +107,7 @@ func resolving(name string) []string {
|
||||
// controller: the seats' verbs, the placed modules' own tools, every machine with its domains and addresses — a
|
||||
// loopback address never one of them.
|
||||
func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
|
||||
m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
||||
m, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -109,7 +119,9 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
|
||||
t.Errorf("replacements: %s", got)
|
||||
}
|
||||
t.Run("the again tool answers the operator and the healers only", func(t *testing.T) {
|
||||
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.mesh-healers": false, "shanks.mesh-config": true} {
|
||||
// An operator's session through the mesh MCP server is proved as its machine's tool runner (issue 627, issue 1966).
|
||||
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.node-tools": false, "shanks.mesh-healers": false,
|
||||
"shanks.mesh-config": true} {
|
||||
if err := seatlist.Refusal("claude_code_seat_list_again", seatListAgainCallers, caller); (err != nil) != refused {
|
||||
t.Errorf("%s: refused=%v, err %v", caller, refused, err)
|
||||
}
|
||||
@@ -132,7 +144,7 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("the seat list was not read: %+v", seatList.Status())
|
||||
}
|
||||
got, err := ReadMeshToolsFrom(aMesh(t), definedSeats(), resolving)
|
||||
got, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), definedSeats(), resolving)
|
||||
var addrs []string
|
||||
for _, r := range got.Replacements {
|
||||
addrs = append(addrs, r.Address)
|
||||
@@ -154,7 +166,7 @@ func TestTheMeshsToolsAreReadFromTheBus(t *testing.T) {
|
||||
// **The table is generated, not written** (novox/hq ADR 0245): what the records say a verb replaces is a row,
|
||||
// the machines' seats first, and nothing is rendered where the records say nothing.
|
||||
func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
|
||||
m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
||||
m, _ := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
|
||||
table := InsteadOf(&m)
|
||||
for _, want := range []string{
|
||||
"| `<node>/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |",
|
||||
@@ -204,7 +216,7 @@ func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
|
||||
// Asked again with the same answer, nothing is written and nothing renders.
|
||||
func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) {
|
||||
p := Paths{State: t.TempDir()}
|
||||
m, _ := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
||||
m, _ := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
|
||||
if !KeepMeshTools(p, m) {
|
||||
t.Fatal("the first answer was not kept")
|
||||
}
|
||||
@@ -241,7 +253,7 @@ func TestTheMachinesRunningAModuleAreReadFromAssignments(t *testing.T) {
|
||||
func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
|
||||
bus := aMesh(t)
|
||||
r := newMeshViews()
|
||||
for _, name := range []string{ModulesRead, ManifestsRead, AssignmentsRead} {
|
||||
for _, name := range []string{AssignmentsRead} {
|
||||
state := strings.TrimPrefix(name, strings.Split(name, ".")[0]+".")
|
||||
values := map[string]json.RawMessage{}
|
||||
for k, v := range bus[name] {
|
||||
@@ -255,16 +267,20 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want, err := ReadMeshToolsFrom(bus, someSeats(), resolving)
|
||||
want, err := ReadMeshToolsFrom(bus, aCatalog(), someSeats(), resolving)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadMeshToolsFrom(viewStates{r}, someSeats(), resolving)
|
||||
got, err := ReadMeshToolsFrom(viewStates{r}, aCatalog(), someSeats(), resolving)
|
||||
if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 {
|
||||
t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want)
|
||||
}
|
||||
// A reader not yet answered says so, and reads nothing.
|
||||
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
|
||||
// A catalog half with no first view says so, and reads no module as absent.
|
||||
if _, err := ReadMeshToolsFrom(bus, fakeCatalog{notYet: errors.New("the catalog half has no first view of main yet")}, someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
|
||||
t.Fatalf("an unanswered catalog half: %v", err)
|
||||
}
|
||||
if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, aCatalog(), someSeats(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") {
|
||||
t.Fatalf("an unanswered first view: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -272,7 +288,7 @@ func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) {
|
||||
// Issue 1126 B13 AC1 (ADR 0590): over the seats the mesh defines, the rendered instructions, their table among them,
|
||||
// name no address of the controller, which is not placed: each job is named by the seat that owns it now.
|
||||
func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) {
|
||||
m, err := ReadMeshToolsFrom(aMesh(t), someSeats(), resolving)
|
||||
m, err := ReadMeshToolsFrom(aMesh(t), aCatalog(), someSeats(), resolving)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ func instructionsText(node, role string) string {
|
||||
node +
|
||||
"`\n- **Role:** " +
|
||||
role +
|
||||
"\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
"\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The operator channel's notify, which notifyOperator calls, is in the manifest's invokes, which the grants part reads per
|
||||
// verb (design 47 G GR8): without it the call is refused live.
|
||||
func TestTheManifestInvokesTheNotifyTheModuleCalls(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"seat:operator-channel.notify"`) {
|
||||
t.Error("the manifest does not invoke seat:operator-channel.notify")
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,13 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
@@ -217,12 +219,19 @@ func nodesRunningMe() ([]string, error) { return NodesRunning(viewStates{meshVie
|
||||
// holds them equal).
|
||||
const (
|
||||
assignerModule = "mesh-delivery-assigner"
|
||||
indexerModule = "mesh-delivery-indexer"
|
||||
)
|
||||
|
||||
// The seat verbs the follower of the watched branches asks (design 47 G §614), as the manifest's `invokes` names them.
|
||||
const (
|
||||
RevisionVerb = "seat:mesh-catalog.revision"
|
||||
RegistrationsVerb = "seat:mesh-catalog.registrations"
|
||||
SourceVerb = "seat:mesh-delivery.source"
|
||||
)
|
||||
|
||||
// meshCatalog is this module's reading of which modules exist: the catalog half, set where the reader is built.
|
||||
var meshCatalog CatalogHalf
|
||||
|
||||
const (
|
||||
ModulesTool = indexerModule + ".indexer_read_modules"
|
||||
ManifestsTool = indexerModule + ".indexer_read_manifests"
|
||||
AssignmentsTool = assignerModule + ".assigner_read_assignments"
|
||||
)
|
||||
|
||||
@@ -230,12 +239,22 @@ const (
|
||||
// once of the owner's read tool, then followed on the owner's views family. It reads none of their state.
|
||||
var meshViews *views.Reader
|
||||
|
||||
// newFindingsWriter is this module's finding writer (design 47 G §689, A-16): it states the findings of its views reader and
|
||||
// observes none of its own.
|
||||
func newFindingsWriter() (*views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent], error) {
|
||||
return views.NewStdioWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{
|
||||
Asked: condseat.SubjectFindingsAsked, Module: "claude-code", State: stdio.State("findings"),
|
||||
Observe: func(context.Context) (views.Observed[condseat.Finding], error) {
|
||||
return views.Observed[condseat.Finding]{}, nil
|
||||
}})
|
||||
}
|
||||
|
||||
// meshChanged is told, without waiting, that a followed state changed: the instructions' table is read again.
|
||||
var meshChanged = make(chan struct{}, 1)
|
||||
|
||||
// newMeshViews builds the reader of those three states; Start asks them.
|
||||
func newMeshViews() *views.Reader {
|
||||
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: meshconditionsclient.FindingWriter{State: stdio.State("findings")},
|
||||
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) },
|
||||
Acts: handActs{},
|
||||
Changed: func(string, string, json.RawMessage, bool) {
|
||||
select {
|
||||
@@ -243,8 +262,6 @@ func newMeshViews() *views.Reader {
|
||||
default:
|
||||
}
|
||||
}}
|
||||
v.Follow(indexerModule, "modules", ModulesTool)
|
||||
v.Follow(indexerModule, "manifests", ManifestsTool)
|
||||
v.Follow(assignerModule, "assignments", AssignmentsTool)
|
||||
return v
|
||||
}
|
||||
@@ -325,7 +342,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after reading the bus again for what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
answer := map[string]any{}
|
||||
if m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup); err != nil {
|
||||
if m, err := ReadMeshToolsFrom(viewStates{meshViews}, meshCatalog, definedSeats(), lookup); err != nil {
|
||||
answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered"
|
||||
} else {
|
||||
KeepMeshTools(p, m)
|
||||
@@ -345,7 +362,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
||||
return GuardStatus(p, last), nil
|
||||
}},
|
||||
{Name: "claude_code_pull",
|
||||
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
|
||||
Description: "Ask the licence manager which licence this node holds and the vault for its access token now, and apply it, rather than waiting for its binding or the token to change.",
|
||||
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
|
||||
{Name: "claude_code_grant",
|
||||
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
|
||||
@@ -459,6 +476,20 @@ func main() {
|
||||
}
|
||||
p, launched := PathsFrom(os.Getenv)
|
||||
meshViews = newMeshViews()
|
||||
branches, err := catalogclient.FollowBranches(meshViews, func(key string, body any) (json.RawMessage, error) { return stdio.Ask(key, body) })
|
||||
if err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
branches.Logf = say
|
||||
// A change in a branch's half is told as any followed state's: the instructions' table is read again.
|
||||
branches.Changed = func(string, string, json.RawMessage, bool, time.Time) {
|
||||
select {
|
||||
case meshChanged <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
meshCatalog = branches
|
||||
if launched {
|
||||
fsys = FilesFor(p)
|
||||
}
|
||||
@@ -488,6 +519,20 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
findings, err := newFindingsWriter()
|
||||
if err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
meshViews.Findings = findings
|
||||
// the runtime answers the writer's subscription only once Serve has its channel
|
||||
go func() {
|
||||
<-stdio.Served()
|
||||
if err := findings.Start(context.Background()); err != nil {
|
||||
say("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
meshViews.Start(context.Background())
|
||||
go refreshMeshTools(p)
|
||||
servers := stateOf{stdio.State("servers")}
|
||||
@@ -567,15 +612,24 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
||||
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
|
||||
// the old inode would go quiet. Fingerprints and expiries only.
|
||||
holdings := stdio.State("holdings")
|
||||
reported := ""
|
||||
report := func() {
|
||||
var sayMu sync.Mutex
|
||||
sayer := &HoldingsSayer{
|
||||
Put: func(h Holdings) error { _, err := holdings.Put(p.Node, h); return err },
|
||||
Emit: func(h Holdings) error { return stdio.Emit(HoldingsChanged, h) },
|
||||
}
|
||||
sayHoldings := func(again bool) {
|
||||
sayMu.Lock()
|
||||
defer sayMu.Unlock()
|
||||
now := HoldingsOf(p)
|
||||
raw, _ := json.Marshal(now)
|
||||
if string(raw) == reported {
|
||||
return
|
||||
}
|
||||
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
|
||||
reported = string(raw)
|
||||
said := false
|
||||
persist("reporting what this node holds", func() error {
|
||||
var err error
|
||||
said, err = sayer.Say(now, again)
|
||||
return err
|
||||
}, func(int) {
|
||||
if !said {
|
||||
return
|
||||
}
|
||||
account := "no account"
|
||||
if now.Identity != nil && now.Identity.EmailAddress != "" {
|
||||
account = now.Identity.EmailAddress
|
||||
@@ -593,30 +647,43 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
||||
say("%s", line)
|
||||
})
|
||||
}
|
||||
|
||||
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
|
||||
// generation is fetched with the seat's `current`, sealed to this module's key.
|
||||
go persist("watching this node's licence binding", func() error {
|
||||
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
|
||||
if c.Key != p.Node {
|
||||
return nil
|
||||
}
|
||||
var b *BindingState
|
||||
if c.Op == "put" {
|
||||
b = &BindingState{}
|
||||
if err := json.Unmarshal(c.Value, b); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
|
||||
say("fetching this node's token failed: %v", err)
|
||||
} else if done != "" {
|
||||
say("%s", done)
|
||||
}
|
||||
go report()
|
||||
report := func() { sayHoldings(false) }
|
||||
// The licence manager asks every node to say what it holds again (§23): after its carry-over, at its start and on
|
||||
// its resync.
|
||||
go persist("hearing the licence manager ask what this node holds", func() error {
|
||||
return stdio.Subscribe(HoldingsAsked, func(stdio.Envelope) error {
|
||||
go sayHoldings(true)
|
||||
return nil
|
||||
})
|
||||
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
|
||||
}, func(n int) { say("hearing %s%s", HoldingsAsked, refusals(n)) })
|
||||
|
||||
// What this node should hold (design 39 bus-and-switch §19, §22): the manager's binding.changed naming this node's
|
||||
// consumer, and its first view, the seat's bindings for this consumer, asked once each time the events are heard
|
||||
// anew (the start, or a reconnect); the token is asked from the vault at that first view and on a newer generation.
|
||||
applied := func(done string, err error) {
|
||||
if err != nil {
|
||||
say("fetching this node's token failed: %v", err)
|
||||
} else if done != "" {
|
||||
say("%s", done)
|
||||
}
|
||||
go report()
|
||||
}
|
||||
// Adopted by this module's views reader (design 47 G A-134): the reader hears binding.changed.<consumer> and asks the
|
||||
// first view once its subscription is taken, and raises and clears that view's findings through the module's writer.
|
||||
if err := meshViews.Adopt(bindingsFollower(p, ask, writeManaged, applied), Manager, BindingChangedEvent,
|
||||
func(_ context.Context, body []byte) error {
|
||||
applied(OnBindingChanged(p, body, ask, writeManaged))
|
||||
return nil
|
||||
}); err != nil {
|
||||
say("this node's licence binding is not followed: %v", err)
|
||||
}
|
||||
// The token of the licence this node holds, asked again when the vault says its access secret changed (§19).
|
||||
go persist("hearing the vault's changes of this node's licence token", func() error {
|
||||
return stdio.Subscribe(SecretChanged, func(e stdio.Envelope) error {
|
||||
applied(OnSecretChanged(p, e.Body, ask, writeManaged))
|
||||
return nil
|
||||
})
|
||||
}, func(n int) { say("hearing %s%s", SecretChanged, refusals(n)) })
|
||||
|
||||
report()
|
||||
var last string
|
||||
@@ -667,8 +734,10 @@ func refusals(n int) string {
|
||||
return fmt.Sprintf(" (after %d refusal(s))", n)
|
||||
}
|
||||
|
||||
// seatListAgainCallers may read the seat list again: every operator's session and the healers (ADR 0636 §7).
|
||||
var seatListAgainCallers = []string{stdio.AnyPerson, "mesh-healers"}
|
||||
// seatListAgainCallers may read the seat list again: every operator's session and the healers (ADR 0636 §7). A call through
|
||||
// the mesh MCP server is proved only as its machine's tool runner (<node>.node-tools, issue 627), so node-tools stands for
|
||||
// an operator's session until a session is proved (issue 1966).
|
||||
var seatListAgainCallers = []string{stdio.AnyPerson, "node-tools", "mesh-healers"}
|
||||
|
||||
// seatListTools are the seat list reader's two tools: what the list holds and what it waits for, and a read now. The
|
||||
// reader starts once the runtime serves, after the tools are listed, so each tool finds it when it is called.
|
||||
|
||||
@@ -16,10 +16,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -28,6 +24,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
assignerclient "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
mesh "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
@@ -93,8 +92,6 @@ func ReadMeshTools(p Paths) *MeshTools {
|
||||
// The states read, each declared in the manifest's reads.
|
||||
const (
|
||||
AssignmentsRead = (assignerModule + "." + mesh.StateAssignments)
|
||||
ModulesRead = indexerModule + "." + catalog.StateModules
|
||||
ManifestsRead = indexerModule + "." + catalog.StateManifests
|
||||
)
|
||||
|
||||
// States reads the module states the manifest declares in its reads: a state's keys, and one key's value.
|
||||
@@ -152,44 +149,28 @@ func NodesRunning(st States, module string) ([]string, error) {
|
||||
return runsOn(placed)[module], nil
|
||||
}
|
||||
|
||||
// modulesTools is every placed module's own tools that replace a command, by module, from the manifest the indexer
|
||||
// keeps of it. A module whose manifest cannot be read is left out and said in the error.
|
||||
func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
|
||||
// modulesTools is every placed module's own tools that replace a command, by module, from the manifest its catalog
|
||||
// graph entry carries. A module whose manifest cannot be read is left out and said in the error.
|
||||
func modulesTools(cat CatalogHalf, on map[string][]string) ([]ModuleTools, error) {
|
||||
var out []ModuleTools
|
||||
var problems []error
|
||||
if err := cat.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modules := make([]string, 0, len(on))
|
||||
for m := range on {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
sort.Strings(modules)
|
||||
for _, module := range modules {
|
||||
key, err := catalogclient.ModulesKey(module)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, found, err := st.Get(ModulesRead, key)
|
||||
entry, found, err := cat.Entry(module)
|
||||
if err != nil || !found {
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("%s %s: %w", ModulesRead, module, err))
|
||||
problems = append(problems, fmt.Errorf("the catalog half's entry of %s: %w", module, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
m, err := catalogclient.DecodeModule(raw)
|
||||
if err != nil || m.Manifest == "" {
|
||||
continue
|
||||
}
|
||||
mkey, err := catalogclient.ManifestsKey(m.Manifest)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, found, err = st.Get(ManifestsRead, mkey)
|
||||
if err != nil || !found {
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("%s of %s: %w", ManifestsRead, module, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept, err := catalogclient.DecodeManifest(raw)
|
||||
kept, err := json.Marshal(entry.Manifest)
|
||||
if err != nil {
|
||||
problems = append(problems, fmt.Errorf("the manifest of %s: %w", module, err))
|
||||
continue
|
||||
@@ -197,7 +178,7 @@ func modulesTools(st States, on map[string][]string) ([]ModuleTools, error) {
|
||||
var man struct {
|
||||
Replaces map[string][]string `json:"replaces"`
|
||||
}
|
||||
if json.Unmarshal([]byte(kept.Manifest), &man) != nil || len(man.Replaces) == 0 {
|
||||
if json.Unmarshal(kept, &man) != nil || len(man.Replaces) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, ModuleTools{Module: module, On: on[module], Replaces: man.Replaces})
|
||||
@@ -263,14 +244,14 @@ func lookup(name string) []string {
|
||||
|
||||
// ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node assignments names
|
||||
// with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept.
|
||||
func ReadMeshToolsFrom(st States, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) {
|
||||
func ReadMeshToolsFrom(st States, cat CatalogHalf, defined []seatdef.Definition, resolve func(string) []string) (MeshTools, error) {
|
||||
var m MeshTools
|
||||
placed, err := assignments(st)
|
||||
if err != nil {
|
||||
m.Replacements = ReplacementsOf(defined, nil)
|
||||
return m, err
|
||||
}
|
||||
modules, merr := modulesTools(st, runsOn(placed))
|
||||
modules, merr := modulesTools(cat, runsOn(placed))
|
||||
m.Replacements = ReplacementsOf(defined, modules)
|
||||
for node, p := range placed {
|
||||
machine := Machine{Name: node, Domains: []string{node + InternalSuffix}}
|
||||
@@ -357,7 +338,7 @@ func refreshMeshTools(p Paths) {
|
||||
}
|
||||
startSeatList(seatlist.StdioBus())
|
||||
for {
|
||||
m, err := ReadMeshToolsFrom(viewStates{meshViews}, definedSeats(), lookup)
|
||||
m, err := ReadMeshToolsFrom(viewStates{meshViews}, meshCatalog, definedSeats(), lookup)
|
||||
if err != nil {
|
||||
say("reading what each tool replaces and which machines the mesh has: %v", err)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,15 @@ package main
|
||||
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
|
||||
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
|
||||
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
|
||||
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
|
||||
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
|
||||
// - What this node should hold is the manager's binding, heard as `binding.changed` and asked at start; the
|
||||
// seat's `current` says which licence, and its `access` secret is asked from the vault, sealed to this
|
||||
// module's key, at start, on a newer generation and on the vault's `secret-changed` for it, and written
|
||||
// access-token-only (design 39 bus-and-switch §19).
|
||||
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
|
||||
// every node, `<node>.<server>` for one — which every node watches.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -28,14 +31,27 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
|
||||
// Seat is the licence manager's role, and Manager the module whose events this one consumes.
|
||||
const (
|
||||
Seat = "anthropic-licence-manager"
|
||||
Manager = "claude-licence-manager"
|
||||
)
|
||||
|
||||
// VaultGet is the vault's verb a licence's access secret is asked by, and SecretChanged the vault's event that it
|
||||
// changed (design 39 bus-and-switch §19, B9).
|
||||
const (
|
||||
VaultGet = "seat:mesh-vault.get"
|
||||
SecretChanged = "mesh-vault.secret-changed"
|
||||
)
|
||||
|
||||
// AccessSecret is the name of a licence's access secret in the vault, by the licence's key as the manager says it
|
||||
// (§15, B7: the key is carried, never formed here).
|
||||
func AccessSecret(key string) string { return "licence." + key + ".access" }
|
||||
|
||||
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
|
||||
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
|
||||
|
||||
@@ -309,13 +325,14 @@ type BindingState struct {
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
||||
// Current is what the seat answers to `current`: the licence this node is bound to, its key and its account, never
|
||||
// its token (§18, B7).
|
||||
type Current struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
Sealed *SealedBox `json:"sealed"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Licence string `json:"licence"`
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
Identity *Identity `json:"identity"`
|
||||
}
|
||||
|
||||
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
|
||||
@@ -404,40 +421,50 @@ func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
|
||||
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
|
||||
}
|
||||
|
||||
// Pull asks the seat for this node's current token and applies it.
|
||||
// Pull asks the seat which licence this node holds, then the vault for that licence's access secret, sealed to this
|
||||
// module's key, and applies it (§19).
|
||||
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
|
||||
keys, err := Keypair(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
|
||||
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Current
|
||||
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
|
||||
if err := json.Unmarshal(raw, &c); err != nil || c.Licence == "" || c.Key == "" {
|
||||
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
|
||||
}
|
||||
return Apply(p, c, write)
|
||||
raw, err = ask(VaultGet, map[string]any{"name": AccessSecret(c.Key), "public_key": keys.PublicKey})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var got struct {
|
||||
Sealed *SealedBox `json:"sealed"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &got); err != nil || got.Sealed == nil {
|
||||
return nil, fmt.Errorf("the vault answered no sealed %s", AccessSecret(c.Key))
|
||||
}
|
||||
return Apply(p, c, *got.Sealed, write)
|
||||
}
|
||||
|
||||
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
|
||||
// fetched when the binding differs from the one applied. A released binding keeps the last token, which
|
||||
// lives hours, and says so.
|
||||
//
|
||||
// **Differs, not "is newer"** (novox/hq issue 243). The state keeps only the latest value per node, so
|
||||
// nothing older can arrive. A manager whose store was rebuilt counts generations from one again, and
|
||||
// a node that waited for a number above its own ignored every binding it was sent, its login and the
|
||||
// licence's rotations included, until the count caught up. Only the binding already applied is skipped.
|
||||
// OnBinding takes this node's binding as the manager said it: the token is fetched when its generation is newer than
|
||||
// the one applied (§19). A home that holds no token of the applied kind has nothing applied, so it is fetched too. A
|
||||
// released binding keeps the last token, which lives hours, and says so.
|
||||
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
||||
if b == nil {
|
||||
return "this node's binding was released; it keeps its last token until it expires", nil
|
||||
}
|
||||
var applied Binding
|
||||
if readJSON(p.binding(), &applied) && applied.Generation == b.Generation && applied.Licence == b.Licence &&
|
||||
holdsApplied(p, applied.Kind) {
|
||||
if readJSON(p.binding(), &applied) && b.Generation <= applied.Generation && holdsApplied(p, applied.Kind) {
|
||||
return "", nil
|
||||
}
|
||||
return fetched(p, b, ask, write)
|
||||
}
|
||||
|
||||
// fetched pulls this node's token for binding b and says the line naming it.
|
||||
func fetched(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
|
||||
out, err := Pull(p, ask, write)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -455,14 +482,104 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
|
||||
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
|
||||
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
||||
// BindingChanged is the manager's event of a binding written or deleted (design 39 bus-and-switch §21), which this
|
||||
// module consumes (§22).
|
||||
const BindingChanged = Manager + "." + BindingChangedEvent
|
||||
|
||||
// BindingChangedEvent is that event's name as the manager says it, with the consumer as its further part
|
||||
// (`binding.changed.<consumer>`, design 47 G A-134).
|
||||
const BindingChangedEvent = "binding.changed"
|
||||
|
||||
// bindingsFollower is this node's first view of its licence binding, adopted by the module's views reader (A-134): its
|
||||
// ask is FirstBinding, whose outcome goes to applied; the picture keeps no binding, which the node's own files hold.
|
||||
func bindingsFollower(p Paths, ask Ask, write WriteManaged, applied func(string, error)) *views.Follower {
|
||||
return &views.Follower{Owner: Manager, Tool: SeatVerb("bindings"), State: "bindings",
|
||||
Ask: func(context.Context, string) (views.Answer, error) {
|
||||
done, err := FirstBinding(p, ask, write)
|
||||
if err != nil {
|
||||
return views.Answer{}, err
|
||||
}
|
||||
applied(done, nil)
|
||||
return views.Answer{Owner: Manager, State: "bindings", Values: map[string]json.RawMessage{}, At: time.Now()}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// bindingOf is a consumer's binding as the manager says it, in binding.changed and in the verb bindings' answer: an
|
||||
// empty licence is a release (B22: decoded from a copy of the writer's form, testdata/binding-changed.json).
|
||||
type bindingOf struct {
|
||||
Consumer string `json:"consumer"`
|
||||
BindingState
|
||||
}
|
||||
|
||||
func (b bindingOf) state() *BindingState {
|
||||
if b.Licence == "" {
|
||||
return nil
|
||||
}
|
||||
return &b.BindingState
|
||||
}
|
||||
|
||||
// OnBindingChanged takes one binding.changed: acted on only when it names this node's consumer (§22).
|
||||
func OnBindingChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) {
|
||||
var b bindingOf
|
||||
if err := json.Unmarshal(body, &b); err != nil {
|
||||
return "", fmt.Errorf("%s is not a binding's change: %w", BindingChanged, err)
|
||||
}
|
||||
if b.Consumer != p.Node {
|
||||
return "", nil
|
||||
}
|
||||
return OnBinding(p, b.state(), ask, write)
|
||||
}
|
||||
|
||||
// FirstBinding asks the seat's verb bindings for this node's consumer, its first view (§22, B10), and applies it: at
|
||||
// the module's start, and again whenever it hears the manager's events anew.
|
||||
func FirstBinding(p Paths, ask Ask, write WriteManaged) (string, error) {
|
||||
raw, err := ask(SeatVerb("bindings"), map[string]any{"consumer": p.Node})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var listed []bindingOf
|
||||
if err := json.Unmarshal(raw, &listed); err != nil {
|
||||
return "", fmt.Errorf("the seat's bindings answered what is not a list of bindings: %w", err)
|
||||
}
|
||||
for _, b := range listed {
|
||||
if b.Consumer == p.Node && b.state() != nil {
|
||||
// At its start the module asks the vault whatever it applied before (§19).
|
||||
return fetched(p, b.state(), ask, write)
|
||||
}
|
||||
}
|
||||
return OnBinding(p, nil, ask, write)
|
||||
}
|
||||
|
||||
// secretChanged is the vault's `secret-changed` as it says it: the secret's name and generation, never its value.
|
||||
type secretChanged struct {
|
||||
Name string `json:"name"`
|
||||
Generation int64 `json:"generation"`
|
||||
}
|
||||
|
||||
// OnSecretChanged takes one `secret-changed`: acted on only when it names the access secret of the licence this node
|
||||
// holds, which is then asked again (§19).
|
||||
func OnSecretChanged(p Paths, body []byte, ask Ask, write WriteManaged) (string, error) {
|
||||
var e secretChanged
|
||||
if err := json.Unmarshal(body, &e); err != nil {
|
||||
return "", fmt.Errorf("%s is not a secret's change: %w", SecretChanged, err)
|
||||
}
|
||||
var applied Binding
|
||||
if !readJSON(p.binding(), &applied) || applied.Key == "" || e.Name != AccessSecret(applied.Key) {
|
||||
return "", nil
|
||||
}
|
||||
return fetched(p, &BindingState{Licence: applied.Licence, Kind: applied.Kind, Generation: applied.Generation,
|
||||
Why: "the vault's " + e.Name + " changed"}, ask, write)
|
||||
}
|
||||
|
||||
// Apply applies the access secret the vault handed over for the licence the seat named. A switch replaces the grant
|
||||
// whole and cleans up after the old licence; whatever it is, the file is written without a refresh token, so the
|
||||
// agent here never refreshes.
|
||||
func Apply(p Paths, c Current, sealed SealedBox, write WriteManaged) (map[string]any, error) {
|
||||
keys, err := Keypair(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := Open(*c.Sealed, keys.PrivateKey)
|
||||
plain, err := Open(sealed, keys.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -522,7 +639,7 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
||||
if gen == 0 {
|
||||
gen = previous.Generation
|
||||
}
|
||||
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
|
||||
raw, _ := json.Marshal(Binding{Licence: c.Licence, Key: c.Key, Kind: c.Kind, Generation: gen})
|
||||
if err := fsys.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -906,10 +906,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
||||
if why := approverReady(bus); why != "" {
|
||||
return refuse(why + "; " + AtTheTerminalInstead)
|
||||
}
|
||||
if waiting := waitingProposals(store, approved, now); waiting >= occ.MostOpen {
|
||||
return refuse(fmt.Sprintf("%d proposals already wait for the operator's answer, and the operator is asked at most %d "+
|
||||
"questions at once; claude_code_proposals lists them", waiting, occ.MostOpen))
|
||||
}
|
||||
if problem := ProposalProblem(prop); problem != "" {
|
||||
return refuse(problem)
|
||||
}
|
||||
@@ -956,26 +952,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
||||
}, nil
|
||||
}
|
||||
|
||||
// waitingProposals counts the proposals still open: asked, not decided here, not expired.
|
||||
func waitingProposals(store ProposalStore, approved Approved, now time.Time) int {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, k := range keys {
|
||||
prop, ok := readProposal(store, k)
|
||||
if !ok || prop.Asked != "" {
|
||||
continue
|
||||
}
|
||||
if _, decided := approved.Decided[prop.ID]; decided || now.After(prop.At.Add(ProposalLasts)) {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func readProposal(store ProposalStore, key string) (Proposal, bool) {
|
||||
raw, found, err := store.Get(key)
|
||||
if err != nil || !found {
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -38,26 +37,8 @@ const QuestionsState = "questions"
|
||||
// QuestionPrefix starts every question's key, so the router's word on one is never taken for a proposal's.
|
||||
const QuestionPrefix = "q-"
|
||||
|
||||
// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks
|
||||
// (occ.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
|
||||
// while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by
|
||||
// create or by compare-and-set, so two nodes asking at once cannot both take the last one.
|
||||
const MostQuestions = occ.MostOpen - 1
|
||||
|
||||
// A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires:
|
||||
// the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can
|
||||
// take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it
|
||||
// cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it
|
||||
// when it is taken (oc.Ask.Check), so the slot is free and the question has ended.
|
||||
|
||||
// slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it.
|
||||
func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) }
|
||||
|
||||
// slot is what a slot key holds: the question holding it.
|
||||
type slot struct {
|
||||
Question string `json:"question"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
// A question is never refused for how many wait: the router offers an asker at most three asks at once and holds the
|
||||
// oldest back behind newer ones, offered again when one ends (novox/hq ADR 0666 §12), so no question is hidden here.
|
||||
|
||||
// The bounds of a question's life (ADR 0332 §2): at least five minutes, at most a day less ten minutes (the
|
||||
// router's bound for an approval, with the margin a proposal keeps), an hour when none is given.
|
||||
@@ -372,19 +353,9 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
||||
if err := occ.Check(ask, now); err != nil {
|
||||
return refuse(err.Error())
|
||||
}
|
||||
// The question is kept before its slot is taken, so a slot always names a question another node can judge.
|
||||
if err := store.Create(q.ID, q); err != nil {
|
||||
return nil, fmt.Errorf("the question could not be kept, so the operator was not asked: %w", err)
|
||||
}
|
||||
if open, err := takeSlot(store, bus, q, now); err != nil {
|
||||
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
|
||||
return refuse(err.Error())
|
||||
} else if open != nil {
|
||||
_ = store.Put(q.ID, tombstone(q, "not asked: two questions were open"))
|
||||
return refuse(fmt.Sprintf("%d questions already wait for the operator's answer (%s), and sessions hold at most %d "+
|
||||
"at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+
|
||||
"claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions))
|
||||
}
|
||||
if err := bus.Publish(oc.AcceptSubjectAsk(Module), ask, ask.ID); err != nil {
|
||||
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
|
||||
return nil, fmt.Errorf("the operator could not be asked: %v", err)
|
||||
@@ -420,75 +391,6 @@ func labelsOf(q Question) string {
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// questionOpen says whether a question still waits: kept, asked, not ended, not expired, and open in the router's
|
||||
// record or not recorded yet. An unreadable record is open: a slot is never freed on a guess.
|
||||
func questionOpen(store QuestionStore, bus AskBus, id string, now time.Time) (bool, error) {
|
||||
q, ok, err := readQuestionErr(store, id)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if !ok || q.Asked != "" || q.Ended != "" || !now.Before(q.Expires) {
|
||||
return false, nil
|
||||
}
|
||||
entry, err := bus.Record(q.ID)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if entry == nil {
|
||||
return true, nil
|
||||
}
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(entry.Value, &rec) != nil {
|
||||
return true, nil
|
||||
}
|
||||
return rec.State == "" || rec.State == "open", nil
|
||||
}
|
||||
|
||||
// takeSlot takes one of the MostQuestions slots for q: a slot no key holds, by create, or one whose question no longer
|
||||
// waits, by compare-and-set at the revision read. Of two nodes taking the same slot only one wins; the other tries
|
||||
// the next. It answers the open questions when every slot is held, and an error — refusing, never guessing — when a
|
||||
// slot or its question cannot be read.
|
||||
func takeSlot(store QuestionStore, bus AskBus, q Question, now time.Time) ([]string, error) {
|
||||
mine := slot{Question: q.ID, At: q.At}
|
||||
var open []string
|
||||
for n := 1; n <= MostQuestions; n++ {
|
||||
key := slotKey(n)
|
||||
raw, rev, found, err := store.Get(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
||||
}
|
||||
if !found {
|
||||
if err := store.Create(key, mine); err == nil {
|
||||
return nil, nil
|
||||
} else if !errors.Is(err, stdio.ErrChanged) {
|
||||
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
||||
}
|
||||
if raw, rev, found, err = store.Get(key); err != nil || !found {
|
||||
return nil, fmt.Errorf("the open questions could not be counted, so none more is asked")
|
||||
}
|
||||
}
|
||||
var held slot
|
||||
if json.Unmarshal(raw, &held) == nil && held.Question != "" {
|
||||
waits, err := questionOpen(store, bus, held.Question, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
||||
}
|
||||
if waits {
|
||||
open = append(open, held.Question)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := store.Update(key, mine, rev); err == nil {
|
||||
return nil, nil
|
||||
} else if !errors.Is(err, stdio.ErrChanged) {
|
||||
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
||||
}
|
||||
open = append(open, "one taken just now")
|
||||
}
|
||||
sort.Strings(open)
|
||||
return open, nil
|
||||
}
|
||||
|
||||
// tombstone is what is kept of a question that was not asked or that the router refused: its key, who asked, when, and
|
||||
// why it ended — none of its words, which the router judged unfit to leave the mesh or never saw.
|
||||
func tombstone(q Question, ended string) Question {
|
||||
@@ -608,7 +510,7 @@ func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map
|
||||
case entry == nil:
|
||||
out["state"] = AnswerWaiting
|
||||
out["words"] = "not yet recorded by the router: it may still be in the router's queue, or was refused without the " +
|
||||
"refusal being heard here. It holds its slot, and the operator may still be asked, until it expires at " +
|
||||
"refusal being heard here. The operator may still be asked until it expires at " +
|
||||
q.Expires.Format(time.RFC3339)
|
||||
case entry == nil, rec.State == "open", rec.Warrant == nil:
|
||||
out["state"] = AnswerWaiting
|
||||
|
||||
@@ -6,7 +6,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -182,22 +181,21 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
|
||||
// ADR 0666 §12: a question is never refused for how many wait; the router holds the oldest back, offered again when one ends.
|
||||
func TestAThirdQuestionIsAskedNotRefusedAndAProposalIsAskedBesideThem(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
first := askMergeTonight(t, p, store, r, t0)
|
||||
askMergeTonight(t, p, store, r, t0)
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "2 questions already wait") {
|
||||
t.Fatalf("a third question: %v", err)
|
||||
for i := 0; i < 4; i++ {
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err != nil {
|
||||
t.Fatalf("question %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
r.close(r.chose(first, "o1", t0.Add(time.Minute)))
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("a question once one was answered: %v", err)
|
||||
if len(r.subjects) != 4 {
|
||||
t.Errorf("the router was asked %d times, want 4", len(r.subjects))
|
||||
}
|
||||
// A proposal is still asked beside two waiting questions.
|
||||
pstore := &memProposals{m: map[string]json.RawMessage{}}
|
||||
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, pstore, r, t0); err != nil {
|
||||
t.Errorf("a proposal beside two questions: %v", err)
|
||||
t.Errorf("a proposal beside the questions: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,9 +232,6 @@ func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
|
||||
if err != nil || out["state"] != AnswerEnded || !strings.Contains(out["words"].(string), "refused") {
|
||||
t.Errorf("read after the refusal: %v %v", out, err)
|
||||
}
|
||||
if waits, err := questionOpen(store, refusing, refusing.id, t0.Add(time.Minute)); waits || err != nil {
|
||||
t.Errorf("a refused question still counts as waiting: %v %v", waits, err)
|
||||
}
|
||||
var kept Question
|
||||
_ = json.Unmarshal(store.m[refusing.id], &kept)
|
||||
if kept.Headline != "" || kept.Context != "" || len(kept.Options) != 0 || kept.Why != "" || kept.OnExpiry != "" {
|
||||
@@ -316,10 +311,6 @@ type memQuestions struct {
|
||||
m map[string]json.RawMessage
|
||||
rev map[string]uint64
|
||||
n uint64
|
||||
// failGet fails every read of a key with this prefix.
|
||||
failGet string
|
||||
// afterGet runs after a read, outside the lock: another node writing between a read and a write.
|
||||
afterGet func(key string)
|
||||
}
|
||||
|
||||
func newMemQuestions() *memQuestions {
|
||||
@@ -328,17 +319,9 @@ func newMemQuestions() *memQuestions {
|
||||
|
||||
func (s *memQuestions) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
s.mu.Lock()
|
||||
if s.failGet != "" && strings.HasPrefix(key, s.failGet) {
|
||||
s.mu.Unlock()
|
||||
return nil, 0, false, errors.New("the bus did not answer")
|
||||
}
|
||||
defer s.mu.Unlock()
|
||||
raw, ok := s.m[key]
|
||||
rev, after := s.rev[key], s.afterGet
|
||||
s.mu.Unlock()
|
||||
if after != nil {
|
||||
after(key)
|
||||
}
|
||||
return raw, rev, ok, nil
|
||||
return raw, s.rev[key], ok, nil
|
||||
}
|
||||
func (s *memQuestions) put(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
@@ -397,121 +380,3 @@ func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused.
|
||||
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
|
||||
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
|
||||
nodes := []string{"g14", "shanks", "ace"}
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(nodes))
|
||||
for i, name := range nodes {
|
||||
p, _ := node(t, name)
|
||||
wg.Add(1)
|
||||
go func(i int, p Paths) {
|
||||
defer wg.Done()
|
||||
in := mergeTonight()
|
||||
in.By = "fix-589"
|
||||
_, errs[i] = AskQuestion(p, in, store, r, t0)
|
||||
}(i, p)
|
||||
}
|
||||
wg.Wait()
|
||||
refused := 0
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "questions already wait") {
|
||||
t.Errorf("refused for another reason: %v", err)
|
||||
}
|
||||
refused++
|
||||
}
|
||||
}
|
||||
if refused != 1 {
|
||||
t.Errorf("%d of three questions asked at once were refused; one is", refused)
|
||||
}
|
||||
}
|
||||
|
||||
// A slot that cannot be read refuses the question: the cap fails closed.
|
||||
func TestTheCapFailsClosed(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
store.failGet = "slot-"
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") {
|
||||
t.Fatalf("asked with the slots unreadable: %v", err)
|
||||
}
|
||||
if len(r.subjects) != 0 {
|
||||
t.Error("the operator was asked anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// A question whose refusal was not heard here, and of which the router holds no record, keeps its slot and reads as
|
||||
// waiting until it expires — the router may still take it from its queue — and only then frees its slot and ends.
|
||||
func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
first := askMergeTonight(t, p, store, r, t0)
|
||||
askMergeTonight(t, p, store, r, t0)
|
||||
delete(r.records, first) // the router down or backlogged, or its refusal never heard here
|
||||
later := t0.Add(QuestionDefault - time.Minute)
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, later); err == nil {
|
||||
t.Fatal("the slot of a question the router may still show was freed before it expired")
|
||||
}
|
||||
out, _ := ReadAnswer(store, r, first, later)
|
||||
if out["state"] != AnswerWaiting || !strings.Contains(out["words"].(string), "not yet recorded") {
|
||||
t.Errorf("before expiry read as %v", out)
|
||||
}
|
||||
expired := t0.Add(QuestionDefault)
|
||||
if out, _ := ReadAnswer(store, r, first, expired); out["state"] != AnswerEnded ||
|
||||
out["now"] != "do what you said: I leave the pull request open" {
|
||||
t.Errorf("at expiry read as %v", out)
|
||||
}
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, expired); err != nil {
|
||||
t.Errorf("the slot of an expired question was not freed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A slot is taken only at the revision read: when another node takes both slots between this node's read and its
|
||||
// write, this node's writes are refused and the question is refused — a plain write would take a slot it lost.
|
||||
func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
// Two questions that ended: their slots are free to take again.
|
||||
for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} {
|
||||
r.close(oc.Warrant{Ask: id, Outcome: occ.OutcomeExpired, At: occ.FormatTime(t0.Add(time.Minute))})
|
||||
}
|
||||
shanks, _ := node(t, "shanks")
|
||||
other := newRouter()
|
||||
store.afterGet = func(key string) {
|
||||
if !strings.HasPrefix(key, "slot-") {
|
||||
return
|
||||
}
|
||||
// Another node asks between this node's read of the slot and its write: its question waits.
|
||||
q := Question{ID: newQuestionID(), Headline: "Another", Context: "c", Why: "w", OnExpiry: "e", By: "other",
|
||||
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
|
||||
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
|
||||
_ = store.Put(q.ID, q)
|
||||
_ = other.Publish(oc.AcceptSubjectAsk(Module), q.Ask(), q.ID)
|
||||
r.records[q.ID] = other.records[q.ID]
|
||||
_ = store.Put(key, slot{Question: q.ID, At: t0})
|
||||
}
|
||||
_, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute))
|
||||
store.afterGet = nil
|
||||
if err == nil || !strings.Contains(err.Error(), "questions already wait") {
|
||||
t.Fatalf("a slot lost to another node was taken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// lockedRouter is the router fake, safe for sessions asking at once.
|
||||
type lockedRouter struct {
|
||||
mu sync.Mutex
|
||||
*router
|
||||
}
|
||||
|
||||
func (l *lockedRouter) Publish(subject string, body any, id string) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.router.Publish(subject, body, id)
|
||||
}
|
||||
func (l *lockedRouter) Record(id string) (*stdio.StateEntry, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.router.Record(id)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@ type Settings struct {
|
||||
|
||||
// Binding is the licence this node holds, as it was last applied.
|
||||
type Binding struct {
|
||||
Licence string `json:"licence"`
|
||||
Licence string `json:"licence"`
|
||||
// Key is the licence's key as the manager says it, which names its access secret in the vault (§19, B7).
|
||||
Key string `json:"key,omitempty"`
|
||||
Kind string `json:"kind"` // subscription | api-key
|
||||
Generation int64 `json:"generation,omitempty"`
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
)
|
||||
|
||||
// A section registered for the mesh before the controller's terminal took that over, or set at the terminal, is
|
||||
|
||||
@@ -60,13 +60,12 @@ func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
|
||||
"{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID)
|
||||
}
|
||||
|
||||
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
|
||||
// family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook
|
||||
// cannot know, so it is offered conditionally; a watcher that rereads the read address always works.
|
||||
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, design 47 E §20): a wait with an event is
|
||||
// followed by mesh_wait on it, rereading what it reads after every answer, or by a watcher that does; with no event, by a
|
||||
// watcher that rereads the read address. The advice puts no condition on a grant.
|
||||
func watcherAdvice(w Wait) string {
|
||||
if w.Family != "" {
|
||||
return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+
|
||||
"that rereads %s", w.Family, w.Read.Address)
|
||||
return fmt.Sprintf("mesh_wait on %s and reread %s after every answer, or start a watcher that does", w.Family, w.Read.Address)
|
||||
}
|
||||
return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address)
|
||||
}
|
||||
|
||||
@@ -300,15 +300,15 @@ func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool
|
||||
// runner may not be granted it); without one, the advice is only to start a watcher.
|
||||
func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) {
|
||||
// Design 47 E §20: a wait with an event is followed by mesh_wait on it and a reread after every answer, or a watcher
|
||||
// that does, with no condition on a grant; without an event, the advice is only to start a watcher.
|
||||
func TestWaitBlocksStopAdvisesMeshWaitOnItsEventOtherwiseAWatcher(t *testing.T) {
|
||||
withFamily := openWait("w-f", 10)
|
||||
withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
|
||||
withFamily.Family = "mesh-delivery.decided"
|
||||
_, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute))
|
||||
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") ||
|
||||
!strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") {
|
||||
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided and reread mesh-delivery.recheck after every answer, or start a watcher that does") ||
|
||||
strings.Contains(reason, "granted") {
|
||||
t.Fatalf("%s", reason)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"at":"2026-10-04T12:00:00Z","by":"g14.node-tools","consumer":"laptop","from":"acct-1@example.org","generation":3,"key":"api","kind":"api-key","licence":"api","why":"the switch verb"}
|
||||
@@ -32,11 +32,11 @@
|
||||
"withKey": {
|
||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
|
||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
},
|
||||
"plain": {
|
||||
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
|
||||
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the seat `mesh-module-assigner` (`mesh-module-assigner.assignments`),\n or `mesh_overview` and `mesh_machine`. Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own seats\n include `mesh-module-assigner`, `mesh-delivery`, `mesh-config` and `mesh-conditions`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the seats' verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `<node>/node-engine.composition` for the module says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch and a pull request for every change.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,11 @@ require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -26,6 +25,10 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 // indirect
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect
|
||||
)
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000 // indirect
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"module": "claude-code",
|
||||
"version": "1",
|
||||
"version": "0.1",
|
||||
"moved": true,
|
||||
"slug": "agent",
|
||||
"capabilities": [
|
||||
@@ -11,6 +11,25 @@
|
||||
{
|
||||
"name": "gitea-read",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"name": "finding-events",
|
||||
"storage": "stream",
|
||||
"template": "events",
|
||||
"options": {
|
||||
"events": [
|
||||
"finding-written.*",
|
||||
"finding-cleared.*",
|
||||
"findings-stated.*"
|
||||
],
|
||||
"max-age-days": 7,
|
||||
"max-mib": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "findings",
|
||||
"storage": "key-value",
|
||||
"template": "state"
|
||||
}
|
||||
],
|
||||
"binds": {
|
||||
@@ -26,16 +45,26 @@
|
||||
"seat:mesh-hand-acts.warranted",
|
||||
"seat:mesh-hand-acts.hand-act",
|
||||
"seat:mesh-catalog.seat-definitions",
|
||||
"mesh-delivery-indexer.indexer_read_modules",
|
||||
"mesh-delivery-indexer.indexer_read_manifests",
|
||||
"mesh-delivery-assigner.assigner_read_assignments"
|
||||
"seat:mesh-catalog.revision",
|
||||
"seat:mesh-catalog.registrations",
|
||||
"seat:mesh-delivery.source",
|
||||
"mesh-delivery-assigner.assigner_read_assignments",
|
||||
"seat:anthropic-licence-manager.bindings",
|
||||
"seat:anthropic-licence-manager.current",
|
||||
"seat:mesh-vault.get",
|
||||
"seat:operator-channel.notify"
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-delivery-indexer.views.modules",
|
||||
"mesh-delivery-indexer.views.manifests",
|
||||
"mesh-catalog.catalog-revision",
|
||||
"mesh-catalog.registration-changed",
|
||||
"mesh-delivery.source-moved",
|
||||
"mesh-delivery-assigner.views.assignments",
|
||||
"mesh-catalog.seat-definition-changed",
|
||||
"mesh-catalog.seat-definition-removed"
|
||||
"mesh-catalog.seat-definition-removed",
|
||||
"mesh-conditions.findings-asked",
|
||||
"claude-licence-manager.holdings.asked",
|
||||
"claude-licence-manager.binding.changed",
|
||||
"mesh-vault.secret-changed"
|
||||
],
|
||||
"state": [
|
||||
"servers",
|
||||
@@ -44,14 +73,11 @@
|
||||
"proposals",
|
||||
"questions",
|
||||
"memory",
|
||||
"waits",
|
||||
"findings"
|
||||
"waits"
|
||||
],
|
||||
"emits": [
|
||||
"judged"
|
||||
],
|
||||
"reads": [
|
||||
"claude-licence-manager.bindings"
|
||||
"judged",
|
||||
"holdings-changed"
|
||||
],
|
||||
"tools": [
|
||||
"claude_code_status",
|
||||
@@ -233,7 +259,16 @@
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"owner": "${machine:account}",
|
||||
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
|
||||
"takes-config": [
|
||||
"role",
|
||||
"mcp_servers",
|
||||
"managed_settings",
|
||||
"instructions",
|
||||
"output_styles",
|
||||
"skills",
|
||||
"commands",
|
||||
"agents"
|
||||
],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json"
|
||||
}
|
||||
|
||||
@@ -1,58 +1,112 @@
|
||||
# claude-licence-manager
|
||||
|
||||
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
|
||||
rotation source, and handed to each consumer sealed (novox/hq ADR 0183, ADR 0206, design 39).
|
||||
rotation source, its consumers moved off a licence near its limit by the manager itself (novox/hq ADR 0183,
|
||||
ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module
|
||||
keeps none.
|
||||
|
||||
It is one manifest of four parts (design 39 parts §1): the manager bundle (`manager`, `cmd/manager`), which runs long
|
||||
(the conditions judged at its start, the usage timer, the switch rule) and answers the seat's verbs and every act; the
|
||||
tools bundle (`tools`, `cmd/tools`), which answers the reading tools and writes nothing, so they answer while the
|
||||
manager fails; the settings file; and the module's account with its directory. Both bundles run as an account of its
|
||||
own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its own, never in the machine's
|
||||
tool runner, so every state, log and secret below is issued to its own account. Its
|
||||
account is issued what its manifest names: the asks under `invokes` (the vault, the operator channel, mesh-config and
|
||||
a node's `claude_code_grant`), the events under `emits` and `consumes`.
|
||||
|
||||
## How a licence comes to exist
|
||||
|
||||
Nothing is configured. Every node running `claude-code` reports what it holds as that module's
|
||||
`holdings` state — the account, fingerprints and expiries, never a token. This module reads every report
|
||||
when it starts and watches them:
|
||||
Nothing is configured. Every node running `claude-code` says what it holds as the event
|
||||
`claude-code.holdings-changed` (the account, fingerprints and expiries, never a token), at its start, on every change,
|
||||
and again when this module says `holdings.asked` (at every start, after its conditions are judged, and on `licences_resync`):
|
||||
|
||||
1. A report with a refresh token it does not hold is a **candidate**.
|
||||
2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it.
|
||||
3. **It refreshes it.** If the vendor exchanges the token, the grant is this module's — encrypted at rest
|
||||
with the key the vault made for it — and from then on it is the only refresher. If not, the candidate
|
||||
is recorded dead and nothing is adopted.
|
||||
3. **It refreshes it**, under the account's lease. If the vendor exchanges the token, the licence is new, named by its
|
||||
account's address and kept under that name's key (`@` as `_at_`, any other character outside letters, digits, `-`
|
||||
and `.` as `_`); its grant and access token go to the vault (`licence-put`), and from then on this module is the
|
||||
only refresher. If not, the candidate is recorded dead and nothing is adopted. An adoption whose key another licence
|
||||
has is refused.
|
||||
4. Several nodes logged in to one account: newest login first; the rest are never exchanged.
|
||||
5. A node reporting that account and bound to nothing is bound to it.
|
||||
5. The node it came from is bound to it; a node reporting that account and bound to nothing is bound to it.
|
||||
|
||||
Each node is then handed an access token only, so the agent there never refreshes, and a refresh token
|
||||
appearing on a node later can only be a person's login — which wins if it refreshes.
|
||||
From its next reading a new subscription has a room and is a target of the switch. An API key enters through `adopt`,
|
||||
from a file on this module's node or sealed by a node's `claude_code_add_api_key`; it is never a target.
|
||||
|
||||
An API key enters through `adopt`, from a file on this module's node.
|
||||
## What it keeps on the bus
|
||||
|
||||
## What each consumer holds
|
||||
Each state asked for in `requires` by its template (ADR 0595), and the two logs declared in the `logs` section, the
|
||||
form the runtime issues today (design 39 bus-and-switch C3); each read by `licences_kept`:
|
||||
|
||||
This module's `bindings` state: one key per consumer (a node's name) with the licence, its kind and a
|
||||
generation that grows with every rotation and switch. `claude-code` watches its own key and, on a newer
|
||||
generation, asks `current` with its public key.
|
||||
| name | template | what |
|
||||
|---|---|---|
|
||||
| `licences` | state, history 1 | one key per licence: account, address, organisation, expiries, failures, adoption, the vault's generation, the latest reading; never a token |
|
||||
| `bindings` | state, history 8 | one key per consumer (a node's name): licence, kind, generation, since, why and by |
|
||||
| `leases` | state, a lifetime per key | `licence.<key>`, `account.<id>`: one refresh or adoption at a time, two minutes |
|
||||
| `offered` | state, a lifetime per key | each grant a node offered and its outcome, 90 days |
|
||||
| `usage` | log, 35 days, 256 MiB | every reading, one subject per licence key, with the vendor's answer whole |
|
||||
| `audit` | log, 64 MiB | every act, one subject per kind (`switched`, `carried`, `settings` among them) |
|
||||
| `carried` | state | `postgres`: the record of the carry-over from the store database, which ran once on 2026-11-12; nothing writes it again |
|
||||
| `conditions` | state | each condition said raised, and since when |
|
||||
|
||||
The carry-over from the store database is done, and its code has left the module with its requirement of the
|
||||
database and of the secret `grant-key` (design 39 parts §9, §10); at its start the manager waits for nothing and
|
||||
clears a `carry-over-failed` it finds kept.
|
||||
|
||||
## Switching on usage
|
||||
|
||||
Every `usage_minutes` the manager reads each subscription's usage with its access token from the vault: the session,
|
||||
weekly and sonnet windows, each with its reset time. A licence's room is 100 less its weekly percentage alone (the session
|
||||
window takes no part in room, the switch, the warning or the conditions: the vendor retired the session limit), known only when it was read within `usage_stale_minutes` and its refresh is healthy. When a reading puts
|
||||
a licence with consumers at or above `switch_at_pct` in its weekly window, every consumer bound to it moves, in one act, to the licence
|
||||
with the most room whose weekly window is below `room_below_pct`: one `binding.changed` per consumer, one
|
||||
`licence.switched`, and one `operator-channel.tell`. A consumer is never moved back by the rule.
|
||||
|
||||
Its conditions are said on the operator channel's condition subject, the router deciding where they go:
|
||||
`licence.<key>.no-room` (urgent; no licence has room), `licence.<key>.near-limit` (at `warn_at_pct`, once per
|
||||
crossing), `licence.<key>.usage-unread` and `settings.<setting>.invalid`.
|
||||
|
||||
## The seat's verbs
|
||||
|
||||
`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through
|
||||
the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
|
||||
`licences`, `bindings` (with `consumer` for one), `bind`, `switch`, `release`, `refresh`, `usage` (`licence`,
|
||||
`since`, `limit` up to 2000, `raw`), `adopt`, `public-key`, `current` (the licence, its key, kind, generation and
|
||||
health, never the token) — through the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
|
||||
|
||||
## Its tools
|
||||
|
||||
Read by the tools bundle: `licences_switches` (every switch and why), `licences_settings` (each value, its default
|
||||
and where it was set), `licences_kept` (every entry it keeps, how full against its cap) and `licences_conditions`.
|
||||
Acting, by the manager bundle: `licences_read_usage` (read now, `dry`), `licences_switch_now` (apply the rule now, even
|
||||
with `switch_auto` off) and `licences_resync` (say every binding, holdings.asked and each condition again, and hand
|
||||
each access token to the vault again).
|
||||
|
||||
## Settings
|
||||
|
||||
`settings.json` in the state directory: `cadence_minutes` (240), `floor_minutes` (60),
|
||||
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3).
|
||||
Through mesh-config, each `trusted`, read again at every reading: `cadence_minutes` (240), `floor_minutes` (60),
|
||||
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3), `switch_auto` (on), `switch_at_pct` (99,
|
||||
1 to 100), `room_below_pct` (90, 1 to 100), `warn_at_pct` (90, 1 to 100), `usage_minutes` (5, 1 to 60),
|
||||
`usage_stale_minutes` (15, 5 to 180). A value outside its bound keeps its default and raises its condition.
|
||||
|
||||
## Events
|
||||
|
||||
`licence.adopted`, `licence.refused`, `licence.failing`, `usage.read` — none carries a secret.
|
||||
`licence.adopted`, `licence.refused`, `licence.switched`, `licence.failing`, `usage.read`, `binding.changed`,
|
||||
`holdings.asked` — none carries a secret. It consumes `claude-code.holdings-changed`.
|
||||
|
||||
## Code and tests
|
||||
|
||||
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs and the daemon in one launched bundle,
|
||||
`prepare` as the run-once preparation step. The sealed box is `claude-code`'s own format, byte for byte —
|
||||
the two modules carry the same `seal.go` — and a test opens one sealed by the TypeScript agent module the
|
||||
Go one replaced, so the format is the one already on the machines.
|
||||
Go, one Go module of two binaries: `cmd/manager` (`claude-licence-manager`: the seat's verbs, the acting tools and the
|
||||
daemon) and `cmd/tools` (`claude-licence-manager-tools`: the reading tools), sharing the package `internal/licences`
|
||||
(the layouts of the kept data and the log entries, the key form, the entries' names and the settings). The
|
||||
sealed box is `claude-code`'s own format, byte for byte — the two modules carry the same `seal.go` — and a test opens
|
||||
one sealed by the TypeScript agent module the Go one replaced, so the format is the one already on the machines.
|
||||
|
||||
go test ./...
|
||||
# the store against a real postgres:
|
||||
docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
|
||||
MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
|
||||
# the whole module on a real bus:
|
||||
go test -tags integration ./...
|
||||
|
||||
The whole tests (`cmd/manager/whole_integration_test.go`) start the built binaries as their runtime does, its kept data in a real
|
||||
nats-server. The vault, the agent modules, the
|
||||
operator channel and the vendor are played beside it; a condition is read through the operator channel's client.
|
||||
|
||||
## Files it does not know
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package main
|
||||
|
||||
// The grants at rest (novox/hq ADR 0183): encrypted with a key the vault made for this module, so the
|
||||
// store holds ciphertext and only this module, reading its own secret, can open a row. AES-256-GCM, the
|
||||
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Crypt seals and opens what the store keeps.
|
||||
type Crypt struct{ key []byte }
|
||||
|
||||
// NewCrypt is the store's cipher from the vault's secret.
|
||||
func NewCrypt(secret string) (*Crypt, error) {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
return nil, errors.New("the key the grants are encrypted with is empty: the vault has not delivered it yet")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return &Crypt{key: sum[:]}, nil
|
||||
}
|
||||
|
||||
// CryptFromFile reads the vault's secret from the file the mesh delivered it to.
|
||||
func CryptFromFile(path string) (*Crypt, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewCrypt(string(raw))
|
||||
}
|
||||
|
||||
// Seal encrypts a plaintext as `v1.<iv>.<ciphertext and tag>`.
|
||||
func (c *Crypt) Seal(plaintext string) string {
|
||||
gcm, _ := newGCM(c.key)
|
||||
iv := make([]byte, 12)
|
||||
_, _ = rand.Read(iv)
|
||||
b64 := base64.StdEncoding.EncodeToString
|
||||
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
|
||||
}
|
||||
|
||||
// Open decrypts what Seal made with the same key.
|
||||
func (c *Crypt) Open(sealed string) (string, error) {
|
||||
parts := strings.Split(sealed, ".")
|
||||
if len(parts) != 3 || parts[0] != "v1" {
|
||||
return "", errors.New("not a grant this module sealed")
|
||||
}
|
||||
iv, err1 := base64.StdEncoding.DecodeString(parts[1])
|
||||
ct, err2 := base64.StdEncoding.DecodeString(parts[2])
|
||||
if err := errors.Join(err1, err2); err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, _ := newGCM(c.key)
|
||||
plain, err := gcm.Open(nil, iv, ct, nil)
|
||||
if err != nil {
|
||||
return "", errors.New("the grant does not open with this module's key")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
package main
|
||||
|
||||
// The store on the mesh's postgres (novox/hq design 39 §1), the database the mesh provisioned for this
|
||||
// module. The schema is brought to this version's shape by the preparation step (ADR 0135), each
|
||||
// statement idempotent.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Schema is what this version needs.
|
||||
var Schema = []string{
|
||||
`create table if not exists licence (
|
||||
name text primary key,
|
||||
kind text not null check (kind in ('subscription', 'api-key')),
|
||||
account_uuid text unique,
|
||||
email text,
|
||||
organization_uuid text,
|
||||
sealed text,
|
||||
refresh_fingerprint text,
|
||||
access_expires_at bigint,
|
||||
refresh_expires_at bigint,
|
||||
failures integer not null default 0,
|
||||
notified_at bigint,
|
||||
adopted_at bigint not null,
|
||||
rotated_at bigint)`,
|
||||
`create sequence if not exists binding_generation`,
|
||||
`create table if not exists binding (
|
||||
consumer text primary key,
|
||||
licence text not null references licence(name),
|
||||
generation bigint not null)`,
|
||||
`create table if not exists lease (
|
||||
key text primary key,
|
||||
holder text not null,
|
||||
until timestamptz not null)`,
|
||||
`create table if not exists offered (
|
||||
fingerprint text primary key,
|
||||
node text not null,
|
||||
account_uuid text,
|
||||
outcome text not null,
|
||||
why text not null,
|
||||
at timestamptz not null default now())`,
|
||||
`create table if not exists usage (
|
||||
licence text not null,
|
||||
at bigint not null,
|
||||
reading jsonb not null,
|
||||
raw jsonb not null)`,
|
||||
`create index if not exists usage_by_licence on usage (licence, at desc)`,
|
||||
`create table if not exists audit (
|
||||
at timestamptz not null default now(),
|
||||
what text not null,
|
||||
detail jsonb not null)`,
|
||||
}
|
||||
|
||||
// PgStore is the store on postgres.
|
||||
type PgStore struct{ pool *pgxpool.Pool }
|
||||
|
||||
// PgStoreFromEnv opens the store the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
|
||||
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
|
||||
file := os.Getenv("DATABASE_URL_FILE")
|
||||
if file == "" {
|
||||
return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves")
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
// OpenPgStore opens a store at a URL.
|
||||
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PgStore{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Migrate brings the schema to this version's shape.
|
||||
func (s *PgStore) Migrate(ctx context.Context) error {
|
||||
for _, q := range Schema {
|
||||
if _, err := s.pool.Exec(ctx, q); err != nil {
|
||||
return fmt.Errorf("%s: %w", strings.Fields(q)[0:6], err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
|
||||
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), coalesce(refresh_expires_at,0),
|
||||
failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)`
|
||||
|
||||
func scanLicence(row pgx.Row) (*Licence, error) {
|
||||
var l Licence
|
||||
err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
|
||||
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return &l, err
|
||||
}
|
||||
|
||||
func (s *PgStore) Licences(ctx context.Context) ([]Licence, error) {
|
||||
rows, err := s.pool.Query(ctx, `select `+licenceColumns+` from licence order by name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Licence
|
||||
for rows.Next() {
|
||||
l, err := scanLicence(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) Licence(ctx context.Context, name string) (*Licence, error) {
|
||||
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where name = $1`, name))
|
||||
}
|
||||
|
||||
func (s *PgStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
|
||||
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where account_uuid = $1`, account))
|
||||
}
|
||||
|
||||
func nullable(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullableInt(v int64) any {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error {
|
||||
_, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint,
|
||||
access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at)
|
||||
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email,
|
||||
organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint,
|
||||
access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at,
|
||||
failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
|
||||
rotated_at = excluded.rotated_at`,
|
||||
l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed),
|
||||
nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
|
||||
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
|
||||
return err
|
||||
}
|
||||
|
||||
// Lease is taken in the store before a row is read (design 39 §3): a second run started together finds
|
||||
// it live and does nothing.
|
||||
func (s *PgStore) Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) {
|
||||
tag, err := s.pool.Exec(ctx, `insert into lease (key, holder, until) values ($1, $2, now() + make_interval(secs => $3))
|
||||
on conflict (key) do update set holder = excluded.holder, until = excluded.until
|
||||
where lease.until < now() or lease.holder = excluded.holder`, key, holder, d.Seconds())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
func (s *PgStore) Unlease(ctx context.Context, key, holder string) error {
|
||||
_, err := s.pool.Exec(ctx, `delete from lease where key = $1 and holder = $2`, key, holder)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) bindingsWhere(ctx context.Context, q string, args ...any) ([]Binding, error) {
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Binding
|
||||
for rows.Next() {
|
||||
var b Binding
|
||||
if err := rows.Scan(&b.Consumer, &b.Licence, &b.Generation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) Bindings(ctx context.Context) ([]Binding, error) {
|
||||
return s.bindingsWhere(ctx, `select consumer, licence, generation from binding order by consumer`)
|
||||
}
|
||||
|
||||
func (s *PgStore) Binding(ctx context.Context, consumer string) (*Binding, error) {
|
||||
out, err := s.bindingsWhere(ctx, `select consumer, licence, generation from binding where consumer = $1`, consumer)
|
||||
if err != nil || len(out) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &out[0], nil
|
||||
}
|
||||
|
||||
func (s *PgStore) Bind(ctx context.Context, consumer, licence string) (Binding, error) {
|
||||
out, err := s.bindingsWhere(ctx, `insert into binding (consumer, licence, generation) values ($1, $2, nextval('binding_generation'))
|
||||
on conflict (consumer) do update set licence = excluded.licence, generation = excluded.generation
|
||||
returning consumer, licence, generation`, consumer, licence)
|
||||
if err != nil {
|
||||
return Binding{}, err
|
||||
}
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
|
||||
tag, err := s.pool.Exec(ctx, `delete from binding where consumer = $1`, consumer)
|
||||
return err == nil && tag.RowsAffected() == 1, err
|
||||
}
|
||||
|
||||
func (s *PgStore) GenerationsAbove(ctx context.Context, generation int64) error {
|
||||
if generation <= 0 {
|
||||
return nil
|
||||
}
|
||||
// setval with is_called, so the next nextval is generation+1; only ever forward.
|
||||
_, err := s.pool.Exec(ctx, `select setval('binding_generation', $1, true) from binding_generation
|
||||
where not is_called or last_value < $1`, generation)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
|
||||
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
|
||||
returning consumer, licence, generation`, licence)
|
||||
}
|
||||
|
||||
func (s *PgStore) Outcome(ctx context.Context, fp string) (Outcome, error) {
|
||||
var o string
|
||||
err := s.pool.QueryRow(ctx, `select outcome from offered where fingerprint = $1`, fp).Scan(&o)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return Outcome(o), err
|
||||
}
|
||||
|
||||
func (s *PgStore) RecordOutcome(ctx context.Context, fp, node, account string, o Outcome, why string) error {
|
||||
_, err := s.pool.Exec(ctx, `insert into offered (fingerprint, node, account_uuid, outcome, why) values ($1,$2,$3,$4,$5)
|
||||
on conflict (fingerprint) do update set outcome = excluded.outcome, why = excluded.why, at = now()`,
|
||||
fp, node, nullable(account), string(o), why)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
|
||||
reading, _ := json.Marshal(r)
|
||||
rawJSON, _ := json.Marshal(raw)
|
||||
_, err := s.pool.Exec(ctx, `insert into usage (licence, at, reading, raw) values ($1,$2,$3,$4)`, licence, at, reading, rawJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) {
|
||||
rows, err := s.pool.Query(ctx, `select licence, at, reading from usage where ($1 = '' or licence = $1) order by at desc limit $2`, licence, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []UsageRow
|
||||
for rows.Next() {
|
||||
var u UsageRow
|
||||
var reading []byte
|
||||
if err := rows.Scan(&u.Licence, &u.At, &reading); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal(reading, &u.Reading)
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PgStore) Audit(ctx context.Context, what string, detail map[string]any) error {
|
||||
raw, _ := json.Marshal(detail)
|
||||
_, err := s.pool.Exec(ctx, `insert into audit (what, detail) values ($1, $2)`, what, raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) Close() { s.pool.Close() }
|
||||
@@ -1,43 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A box the agent module's TypeScript sealed opens here: the two implementations are one format.
|
||||
func TestABoxSealedInTypeScriptOpensInGo(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/sealed-by-typescript.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f struct {
|
||||
PrivateKey string `json:"privateKey"`
|
||||
Box SealedBox `json:"box"`
|
||||
Plaintext string `json:"plaintext"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Open(f.Box, f.PrivateKey)
|
||||
if err != nil || got != f.Plaintext {
|
||||
t.Fatalf("opened %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// What Go seals opens with its own key and no other.
|
||||
func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
|
||||
a, _ := GenerateKeyPair()
|
||||
b, _ := GenerateKeyPair()
|
||||
box, err := Seal("a token", a.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" {
|
||||
t.Fatalf("opened %q, %v", got, err)
|
||||
}
|
||||
if _, err := Open(box, b.PrivateKey); err == nil {
|
||||
t.Fatal("a box opened for another key")
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package main
|
||||
|
||||
// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings
|
||||
// with a generation, what became of each login it was offered, usage readings, and the audit. One
|
||||
// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every
|
||||
// rule is tested without a database, and the database is asked only to keep rows.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Licence is one licence: an account, or an API key.
|
||||
type Licence struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // subscription | api-key
|
||||
AccountUUID string `json:"accountUuid,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
OrganizationUUID string `json:"organizationUuid,omitempty"`
|
||||
Sealed string `json:"-"` // the grant or the key, encrypted at rest
|
||||
RefreshFingerprint string `json:"-"`
|
||||
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
|
||||
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
|
||||
Failures int `json:"failures"`
|
||||
NotifiedAt int64 `json:"-"`
|
||||
AdoptedAt int64 `json:"adoptedAt"`
|
||||
RotatedAt int64 `json:"rotatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Binding is one consumer's binding and the generation it was last given (ADR 0206).
|
||||
type Binding struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Licence string `json:"licence"`
|
||||
Generation int64 `json:"generation"`
|
||||
}
|
||||
|
||||
// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
Adopted Outcome = "adopted"
|
||||
Dead Outcome = "dead"
|
||||
Skipped Outcome = "skipped"
|
||||
Refused Outcome = "refused"
|
||||
Gone Outcome = "gone"
|
||||
)
|
||||
|
||||
// UsageRow is one usage reading.
|
||||
type UsageRow struct {
|
||||
Licence string `json:"licence"`
|
||||
At int64 `json:"at"`
|
||||
Reading UsageReading `json:"reading"`
|
||||
}
|
||||
|
||||
// Store is what the manager keeps.
|
||||
type Store interface {
|
||||
Licences(ctx context.Context) ([]Licence, error)
|
||||
Licence(ctx context.Context, name string) (*Licence, error)
|
||||
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
|
||||
SaveLicence(ctx context.Context, l Licence) error
|
||||
// Lease takes a lease for d, or answers false while another holder's is live.
|
||||
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
|
||||
Unlease(ctx context.Context, key, holder string) error
|
||||
Bindings(ctx context.Context) ([]Binding, error)
|
||||
Binding(ctx context.Context, consumer string) (*Binding, error)
|
||||
// Bind binds (or switches) a consumer at the next generation.
|
||||
Bind(ctx context.Context, consumer, licence string) (Binding, error)
|
||||
Unbind(ctx context.Context, consumer string) (bool, error)
|
||||
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
|
||||
Advance(ctx context.Context, licence string) ([]Binding, error)
|
||||
// GenerationsAbove makes every generation given from now on greater than this one.
|
||||
GenerationsAbove(ctx context.Context, generation int64) error
|
||||
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
|
||||
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
|
||||
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
|
||||
Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error)
|
||||
Audit(ctx context.Context, what string, detail map[string]any) error
|
||||
Close()
|
||||
}
|
||||
|
||||
// MemoryStore is the tests' store.
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
rows map[string]Licence
|
||||
binds map[string]Binding
|
||||
leases map[string]struct {
|
||||
holder string
|
||||
until time.Time
|
||||
}
|
||||
outcomes map[string]Outcome
|
||||
usage []UsageRow
|
||||
Audits []map[string]any
|
||||
generation int64
|
||||
}
|
||||
|
||||
// NewMemoryStore is an empty store whose leases age by now.
|
||||
func NewMemoryStore(now func() time.Time) *MemoryStore {
|
||||
return &MemoryStore{now: now, rows: map[string]Licence{}, binds: map[string]Binding{},
|
||||
leases: map[string]struct {
|
||||
holder string
|
||||
until time.Time
|
||||
}{}, outcomes: map[string]Outcome{}}
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Licences(context.Context) ([]Licence, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]Licence, 0, len(m.rows))
|
||||
for _, l := range m.rows {
|
||||
out = append(out, l)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if l, ok := m.rows[name]; ok {
|
||||
return &l, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, l := range m.rows {
|
||||
if l.AccountUUID == account {
|
||||
return &l, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.rows[l.Name] = l
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder {
|
||||
return false, nil
|
||||
}
|
||||
m.leases[key] = struct {
|
||||
holder string
|
||||
until time.Time
|
||||
}{holder, m.now().Add(d)}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.leases[key].holder == holder {
|
||||
delete(m.leases, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]Binding, 0, len(m.binds))
|
||||
for _, b := range m.binds {
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if b, ok := m.binds[consumer]; ok {
|
||||
return &b, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.generation++
|
||||
b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation}
|
||||
m.binds[consumer] = b
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, ok := m.binds[consumer]
|
||||
delete(m.binds, consumer)
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.generation = max(m.generation, generation)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []Binding
|
||||
for c, b := range m.binds {
|
||||
if b.Licence != licence {
|
||||
continue
|
||||
}
|
||||
m.generation++
|
||||
b.Generation = m.generation
|
||||
m.binds[c] = b
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.outcomes[fp], nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.outcomes[fp] = o
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []UsageRow
|
||||
for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
if licence == "" || m.usage[i].Licence == licence {
|
||||
out = append(out, m.usage[i])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := map[string]any{"what": what}
|
||||
for k, v := range detail {
|
||||
d[k] = v
|
||||
}
|
||||
m.Audits = append(m.Audits, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Close() {}
|
||||
@@ -1,126 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Against a real postgres, because the questions are the database's: does the schema apply twice, does a
|
||||
// lease refuse a second holder, does a generation only grow. Skipped unless one is named:
|
||||
//
|
||||
// docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
|
||||
// MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
|
||||
func TestTheStoreOnPostgres(t *testing.T) {
|
||||
url := os.Getenv("MESH_TEST_POSTGRES")
|
||||
if url == "" {
|
||||
t.Skip("MESH_TEST_POSTGRES unset")
|
||||
}
|
||||
ctx := context.Background()
|
||||
s, err := OpenPgStore(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
for _, table := range []string{"binding", "licence", "lease", "offered", "usage", "audit"} {
|
||||
_, _ = s.pool.Exec(ctx, "drop table if exists "+table+" cascade")
|
||||
}
|
||||
_, _ = s.pool.Exec(ctx, "drop sequence if exists binding_generation")
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := s.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migration %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y",
|
||||
RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
|
||||
if err := s.SaveLicence(ctx, l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.Failures = 2
|
||||
_ = s.SaveLicence(ctx, l)
|
||||
if got, _ := s.LicenceForAccount(ctx, "u-1"); got == nil || got.Failures != 2 || got.OrganizationUUID != "" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if none, err := s.Licence(ctx, "nobody"); none != nil || err != nil {
|
||||
t.Fatalf("%v %v", none, err)
|
||||
}
|
||||
lease := func(holder string) bool {
|
||||
ok, err := s.Lease(ctx, "licence:a", holder, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
if !lease("one") || lease("two") || !lease("one") {
|
||||
t.Fatal("a lease did not refuse a second holder, or its own holder could not renew it")
|
||||
}
|
||||
_ = s.Unlease(ctx, "licence:a", "one")
|
||||
if !lease("two") {
|
||||
t.Fatal("a released lease was not taken")
|
||||
}
|
||||
b1, _ := s.Bind(ctx, "laptop", l.Name)
|
||||
adv, _ := s.Advance(ctx, l.Name)
|
||||
b3, _ := s.Bind(ctx, "laptop", l.Name)
|
||||
if len(adv) != 1 || !(b1.Generation < adv[0].Generation && adv[0].Generation < b3.Generation) {
|
||||
t.Fatalf("generations %d %v %d", b1.Generation, adv, b3.Generation)
|
||||
}
|
||||
_ = s.RecordOutcome(ctx, "sha256:x", "laptop", "u-1", Dead, "400")
|
||||
if o, _ := s.Outcome(ctx, "sha256:x"); o != Dead {
|
||||
t.Fatalf("outcome %q", o)
|
||||
}
|
||||
if o, _ := s.Outcome(ctx, "sha256:none"); o != "" {
|
||||
t.Fatalf("an unknown login is %q", o)
|
||||
}
|
||||
pct := 1.0
|
||||
_ = s.RecordUsage(ctx, l.Name, 5, UsageReading{SessionPct: &pct}, map[string]any{})
|
||||
if u, _ := s.Usage(ctx, "", 5); len(u) != 1 || *u[0].Reading.SessionPct != 1 {
|
||||
t.Fatalf("usage %v", u)
|
||||
}
|
||||
if err := s.Audit(ctx, "bound", map[string]any{"consumer": "laptop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok, _ := s.Unbind(ctx, "laptop"); !ok {
|
||||
t.Fatal("unbind")
|
||||
}
|
||||
all, _ := s.Licences(ctx)
|
||||
if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") {
|
||||
t.Fatalf("%v", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
||||
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
|
||||
in := int64(60)
|
||||
kept := NextGrant(prev, tokenResponse{AccessToken: "a2", ExpiresIn: &in}, 1000)
|
||||
if !kept.OK || kept.Grant.RefreshToken != "r" || kept.Grant.ExpiresAt != 61_000 {
|
||||
t.Fatalf("%+v", kept)
|
||||
}
|
||||
rotated := NextGrant(prev, tokenResponse{AccessToken: "a3", RefreshToken: "r2"}, 0)
|
||||
if rotated.Grant.RefreshToken != "r2" {
|
||||
t.Fatalf("%+v", rotated)
|
||||
}
|
||||
if NextGrant(prev, tokenResponse{}, 0).OK {
|
||||
t.Fatal("an answer with no access token was a grant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
|
||||
a, _ := NewCrypt("one key")
|
||||
b, _ := NewCrypt("another key")
|
||||
sealed := a.Seal(`{"refreshToken":"rt"}`)
|
||||
// The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
|
||||
if strings.Contains(sealed, "refreshToken") {
|
||||
t.Fatal("stored in the clear")
|
||||
}
|
||||
if got, err := a.Open(sealed); err != nil || got != `{"refreshToken":"rt"}` {
|
||||
t.Fatalf("%q %v", got, err)
|
||||
}
|
||||
if _, err := b.Open(sealed); err == nil {
|
||||
t.Fatal("opened with another key")
|
||||
}
|
||||
if _, err := NewCrypt(" "); err == nil {
|
||||
t.Fatal("an empty key was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package main
|
||||
|
||||
// The manager's kept data on the bus (design 39 bus-and-switch §2, §3, B11, B22): the state `licences`, one key per
|
||||
// licence by its key form (§10), and the state `bindings`, one key per consumer, its value the form the consumers
|
||||
// have always read. Written by the manager alone.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// BusStore keeps the manager's data in the bus's own storage.
|
||||
type BusStore struct {
|
||||
LicencesState State
|
||||
BindingsState State
|
||||
LeasesState State
|
||||
OfferedState State
|
||||
UsageLog Log
|
||||
AuditLog Log
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (s *BusStore) get(st State, key string, into any) (bool, error) {
|
||||
e, err := st.Get(key)
|
||||
if err != nil || e == nil || len(e.Value) == 0 || string(e.Value) == "null" {
|
||||
return false, err
|
||||
}
|
||||
return true, json.Unmarshal(e.Value, into)
|
||||
}
|
||||
|
||||
func (s *BusStore) Licences(context.Context) ([]Licence, error) {
|
||||
keys, err := s.LicencesState.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Licence{}
|
||||
for _, k := range keys {
|
||||
var l Licence
|
||||
if ok, err := s.get(s.LicencesState, k, &l); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BusStore) Licence(_ context.Context, name string) (*Licence, error) {
|
||||
var l Licence
|
||||
ok, err := s.get(s.LicencesState, KeyFor(name), &l)
|
||||
if err != nil || !ok || l.Name != name {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (s *BusStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
|
||||
all, err := s.Licences(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range all {
|
||||
if l.AccountUUID == account {
|
||||
return &l, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *BusStore) SaveLicence(_ context.Context, l Licence) error {
|
||||
if l.Name == "" {
|
||||
return errors.New("a licence has a name")
|
||||
}
|
||||
_, err := s.LicencesState.Put(KeyFor(l.Name), l)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *BusStore) Bindings(context.Context) ([]Binding, error) {
|
||||
keys, err := s.BindingsState.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Binding{}
|
||||
for _, k := range keys {
|
||||
if b, err := s.binding(k); err != nil {
|
||||
return nil, err
|
||||
} else if b != nil {
|
||||
out = append(out, *b)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *BusStore) binding(consumer string) (*Binding, error) {
|
||||
var v BindingState
|
||||
ok, err := s.get(s.BindingsState, consumer, &v)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
return &Binding{Consumer: consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Since: v.Since,
|
||||
Why: v.Why, By: v.By}, nil
|
||||
}
|
||||
|
||||
func (s *BusStore) Binding(_ context.Context, consumer string) (*Binding, error) {
|
||||
return s.binding(consumer)
|
||||
}
|
||||
|
||||
// Bind writes a consumer's key at its next generation: one more than the higher of its own and the floor (B11).
|
||||
func (s *BusStore) Bind(_ context.Context, consumer, licence string, m Move) (Binding, error) {
|
||||
before, err := s.binding(consumer)
|
||||
if err != nil {
|
||||
return Binding{}, err
|
||||
}
|
||||
generation := m.floor(consumer)
|
||||
if before != nil {
|
||||
generation = max(generation, before.Generation)
|
||||
}
|
||||
b := Binding{Consumer: consumer, Licence: licence, Kind: m.Kind, Generation: generation + 1,
|
||||
Since: s.Now().UTC().Format(time.RFC3339), Why: m.Why, By: m.By}
|
||||
_, err = s.BindingsState.Put(consumer, BindingState{Licence: b.Licence, Kind: b.Kind, Generation: b.Generation,
|
||||
Since: b.Since, Why: b.Why, By: b.By})
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (s *BusStore) Unbind(_ context.Context, consumer string) (bool, error) {
|
||||
before, err := s.binding(consumer)
|
||||
if err != nil || before == nil {
|
||||
return false, err
|
||||
}
|
||||
return true, s.BindingsState.Delete(consumer)
|
||||
}
|
||||
|
||||
// Advance gives every consumer bound to licence its next generation, keeping its licence and since.
|
||||
func (s *BusStore) Advance(ctx context.Context, licence string, m Move) ([]Binding, error) {
|
||||
all, err := s.Bindings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Binding
|
||||
for _, b := range all {
|
||||
if b.Licence != licence {
|
||||
continue
|
||||
}
|
||||
b.Generation = max(b.Generation, m.floor(b.Consumer)) + 1
|
||||
b.Kind, b.Why, b.By = m.Kind, m.Why, m.By
|
||||
if _, err := s.BindingsState.Put(b.Consumer, BindingState{Licence: b.Licence, Kind: b.Kind,
|
||||
Generation: b.Generation, Since: b.Since, Why: b.Why, By: b.By}); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- leases and offers (§4, §5) ---------------------------------------------------------------------------------
|
||||
|
||||
// Lease takes a lease by creating its key with a lifetime of d (§4): a create fails while another holds it, the bus's
|
||||
// own compare-and-set. A key its own holder holds is taken again.
|
||||
func (s *BusStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
|
||||
v := LeaseValue{Holder: holder, Until: s.Now().Add(d).UTC().Format(time.RFC3339)}
|
||||
_, err := s.LeasesState.CreateTTL(key, v, d)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if !errors.Is(err, stdio.ErrChanged) {
|
||||
return false, err
|
||||
}
|
||||
var held LeaseValue
|
||||
if ok, gerr := s.get(s.LeasesState, key, &held); gerr != nil || !ok || held.Holder != holder {
|
||||
return false, gerr
|
||||
}
|
||||
_, err = s.LeasesState.PutTTL(key, v, d)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// Unlease deletes a lease its holder holds; another's is left to it or its lifetime.
|
||||
func (s *BusStore) Unlease(_ context.Context, key, holder string) error {
|
||||
var held LeaseValue
|
||||
if ok, err := s.get(s.LeasesState, key, &held); err != nil || !ok || held.Holder != holder {
|
||||
return err
|
||||
}
|
||||
return s.LeasesState.Delete(key)
|
||||
}
|
||||
|
||||
// offeredFor is how long the outcome of an offered grant is kept (§5): longer than a refresh token lives.
|
||||
const offeredFor = 90 * 24 * time.Hour
|
||||
|
||||
// OfferedKey is a fingerprint's key in `offered`: through the licence key form (B13).
|
||||
func OfferedKey(fingerprint string) string { return KeyFor(fingerprint) }
|
||||
|
||||
func (s *BusStore) Outcome(_ context.Context, fingerprint string) (Outcome, error) {
|
||||
var v OfferedValue
|
||||
if ok, err := s.get(s.OfferedState, OfferedKey(fingerprint), &v); err != nil || !ok {
|
||||
return "", err
|
||||
}
|
||||
return v.Outcome, nil
|
||||
}
|
||||
|
||||
func (s *BusStore) RecordOutcome(_ context.Context, fingerprint, node, account string, o Outcome, why string) error {
|
||||
return s.putOffered(fingerprint, OfferedValue{Node: node, Account: account, Outcome: o, Why: why,
|
||||
At: s.Now().UTC().Format(time.RFC3339)}, offeredFor)
|
||||
}
|
||||
|
||||
func (s *BusStore) putOffered(fingerprint string, v OfferedValue, ttl time.Duration) error {
|
||||
_, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- the logs (§6, §7) ------------------------------------------------------------------------------------------
|
||||
|
||||
func (s *BusStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
|
||||
_, err := s.UsageLog.Append(KeyFor(licence), UsageEntry{At: time.UnixMilli(at).UTC().Format(time.RFC3339Nano),
|
||||
Reading: r, Raw: raw})
|
||||
return err
|
||||
}
|
||||
|
||||
// Usage is the latest readings, newest first, of one licence or every one, none before since, the vendor's answer
|
||||
// with raw (§35).
|
||||
func (s *BusStore) Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]UsageRow, error) {
|
||||
names := map[string]string{}
|
||||
all, err := s.Licences(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range all {
|
||||
names[KeyFor(l.Name)] = l.Name
|
||||
}
|
||||
keys := ""
|
||||
if licence != "" {
|
||||
keys = KeyFor(licence)
|
||||
}
|
||||
entries, err := s.newest(s.UsageLog, keys, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []UsageRow{}
|
||||
for _, e := range entries {
|
||||
var u UsageEntry
|
||||
if json.Unmarshal(e.Value, &u) != nil {
|
||||
continue
|
||||
}
|
||||
at, _ := time.Parse(time.RFC3339Nano, u.At)
|
||||
if at.Before(since) {
|
||||
continue
|
||||
}
|
||||
name := names[e.Key]
|
||||
if name == "" {
|
||||
name = e.Key
|
||||
}
|
||||
row := UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading}
|
||||
if raw {
|
||||
row.Raw = u.Raw
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newest is up to limit entries of keys, newest first: read back from the log's last sequence, a window that doubles
|
||||
// until it holds limit entries or reaches the first.
|
||||
func (s *BusStore) newest(l Log, keys string, limit int) ([]stdio.LogEntry, error) {
|
||||
info, err := l.Info()
|
||||
if err != nil || info.Last == 0 {
|
||||
return nil, err
|
||||
}
|
||||
for span := uint64(max(limit, 1)); ; span *= 2 {
|
||||
from := uint64(1)
|
||||
if info.Last > span {
|
||||
from = info.Last - span + 1
|
||||
}
|
||||
var got []stdio.LogEntry
|
||||
for next := from; next != 0; {
|
||||
page, err := l.Read(next, keys, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
got = append(got, page.Entries...)
|
||||
next = page.Next
|
||||
}
|
||||
if len(got) >= limit || from <= max(info.First, 1) {
|
||||
if len(got) > limit {
|
||||
got = got[len(got)-limit:]
|
||||
}
|
||||
for i, j := 0, len(got)-1; i < j; i, j = i+1, j-1 {
|
||||
got[i], got[j] = got[j], got[i]
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit appends one entry under its kind, with when; a log that does not take it fails the act (B20).
|
||||
func (s *BusStore) Audit(_ context.Context, what string, detail map[string]any) error {
|
||||
kind := "drift"
|
||||
for _, k := range AuditKinds {
|
||||
if k == what {
|
||||
kind = k
|
||||
}
|
||||
}
|
||||
entry := map[string]any{"at": s.Now().UTC().Format(time.RFC3339Nano)}
|
||||
if kind != what {
|
||||
entry["what"] = what
|
||||
}
|
||||
for k, v := range detail {
|
||||
entry[k] = v
|
||||
}
|
||||
if _, err := s.AuditLog.Append(kind, entry); err != nil {
|
||||
return fmt.Errorf("the audit did not take the %s entry, so the act is not done: %w", what, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// memState is a state of the bus as the runtime answers it, in memory: values, their past values, keys' lifetimes.
|
||||
type memState struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
values map[string]json.RawMessage
|
||||
until map[string]time.Time
|
||||
history map[string][]stdio.StateRevision
|
||||
rev uint64
|
||||
writes int
|
||||
}
|
||||
|
||||
func newMemState(now func() time.Time) *memState {
|
||||
return &memState{now: now, values: map[string]json.RawMessage{}, until: map[string]time.Time{},
|
||||
history: map[string][]stdio.StateRevision{}}
|
||||
}
|
||||
|
||||
func (s *memState) live(key string) bool {
|
||||
if u, ok := s.until[key]; ok && !s.now().Before(u) {
|
||||
delete(s.values, key)
|
||||
delete(s.until, key)
|
||||
}
|
||||
_, ok := s.values[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *memState) Get(key string) (*stdio.StateEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.live(key) {
|
||||
return nil, nil
|
||||
}
|
||||
return &stdio.StateEntry{Key: key, Value: s.values[key]}, nil
|
||||
}
|
||||
|
||||
func (s *memState) write(key string, value any, ttl time.Duration, create bool) (uint64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if create && s.live(key) {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.rev++
|
||||
s.writes++
|
||||
s.values[key] = raw
|
||||
delete(s.until, key)
|
||||
if ttl > 0 {
|
||||
s.until[key] = s.now().Add(ttl)
|
||||
}
|
||||
s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Value: raw, Revision: s.rev, Op: "put", At: s.now()})
|
||||
return s.rev, nil
|
||||
}
|
||||
|
||||
func (s *memState) Put(key string, value any) (uint64, error) { return s.write(key, value, 0, false) }
|
||||
func (s *memState) PutTTL(key string, value any, ttl time.Duration) (uint64, error) {
|
||||
return s.write(key, value, ttl, false)
|
||||
}
|
||||
func (s *memState) Create(key string, value any) (uint64, error) { return s.write(key, value, 0, true) }
|
||||
func (s *memState) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) {
|
||||
return s.write(key, value, ttl, true)
|
||||
}
|
||||
|
||||
func (s *memState) Delete(key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.rev++
|
||||
s.writes++
|
||||
delete(s.values, key)
|
||||
delete(s.until, key)
|
||||
s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Revision: s.rev, Op: "delete", At: s.now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memState) Keys() ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := []string{}
|
||||
for k := range s.values {
|
||||
if s.live(k) {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *memState) History(key string) ([]stdio.StateRevision, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]stdio.StateRevision(nil), s.history[key]...), nil
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §2: a rotation and a reading each write the licence's key, by its key form, and no field
|
||||
// holds a token.
|
||||
func TestARotationAndAReadingWriteTheLicenceKey(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
key := KeyFor(licence1)
|
||||
before := len(mm.licences.history[key])
|
||||
if _, err := mm.m.Rotate(ctx, licence1, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(mm.licences.history[key]); got < before+2 {
|
||||
t.Fatalf("the key %s was written %d times by a rotation and a reading, want 2", key, got-before)
|
||||
}
|
||||
for _, r := range mm.licences.history[key] {
|
||||
for _, token := range []string{"rt-", "at-"} {
|
||||
if strings.Contains(string(r.Value), token) {
|
||||
t.Fatalf("a value of %s holds %s: %s", key, token, r.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
var l Licence
|
||||
_ = json.Unmarshal(mm.licences.values[key], &l)
|
||||
if l.Name != licence1 || l.AccountUUID != "acct-1" || !strings.Contains(string(mm.licences.values[key]), `"account":"acct-1"`) {
|
||||
t.Fatalf("the key holds %+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §3, B11: bind, switch, release and a rotation each write or delete the consumer's key with
|
||||
// its why and by; each consumer counts its own generations, past what its node reported.
|
||||
func TestEachMoveWritesTheBindingWithWhyAndBy(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
|
||||
mm.m.Reported = func(node string) int64 {
|
||||
if node == "server" {
|
||||
return 40
|
||||
}
|
||||
return 0
|
||||
}
|
||||
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "jane"})
|
||||
if _, err := mm.m.Bind(asked, "server", "api"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b := mm.bound("server"); b.Licence != "api" || b.Generation != 41 || b.Why != "the switch verb" || b.By != "jane" ||
|
||||
b.Since == "" || b.Kind != "api-key" {
|
||||
t.Fatalf("server's binding %+v", b)
|
||||
}
|
||||
laptop := mm.bound("laptop").Generation
|
||||
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b := mm.bound("laptop"); b.Generation != laptop+1 || b.Licence != "api" {
|
||||
t.Fatalf("laptop's switch %+v, from generation %d", b, laptop)
|
||||
}
|
||||
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "jane"}), "server"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := mm.bindings.values["server"]; ok {
|
||||
t.Fatal("release left the key")
|
||||
}
|
||||
if h, _ := mm.bindings.History("server"); len(h) != 2 || h[1].Op != "delete" {
|
||||
t.Fatalf("server's history %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §4, §5, B13: a lease is a key created with a two-minute lifetime that another holder cannot
|
||||
// create; an offered grant's outcome is kept under its fingerprint in the key form for 90 days.
|
||||
func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
key := "licence." + KeyFor(licence1)
|
||||
if ok, err := mm.store.Lease(ctx, key, "one", leaseFor); !ok || err != nil {
|
||||
t.Fatalf("the first lease: %v %v", ok, err)
|
||||
}
|
||||
if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); ok {
|
||||
t.Fatal("a second holder took a live lease")
|
||||
}
|
||||
if u := mm.leases.until[key]; !u.Equal(mm.now.Add(2 * time.Minute)) {
|
||||
t.Fatalf("the lease lives until %v", u)
|
||||
}
|
||||
mm.now = mm.now.Add(2 * time.Minute)
|
||||
if ok, _ := mm.store.Lease(ctx, key, "two", leaseFor); !ok {
|
||||
t.Fatal("a lease past its lifetime was not taken")
|
||||
}
|
||||
_ = mm.store.Unlease(ctx, key, "one")
|
||||
if _, held := mm.leases.values[key]; !held {
|
||||
t.Fatal("another holder's lease was ended")
|
||||
}
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-dead", false, t0)})
|
||||
fp := OfferedKey(Fingerprint("rt-dead"))
|
||||
if strings.ContainsAny(fp, ":") || !strings.HasPrefix(fp, "sha256_") {
|
||||
t.Fatalf("the offered key %q is not in the key form", fp)
|
||||
}
|
||||
var v OfferedValue
|
||||
_ = json.Unmarshal(mm.offered.values[fp], &v)
|
||||
if v.Outcome != Dead || v.Node != "laptop" || v.Account != "acct-1" || v.Why == "" || v.At == "" {
|
||||
t.Fatalf("offered %+v", v)
|
||||
}
|
||||
if u := mm.offered.until[fp]; !u.Equal(mm.now.Add(90 * 24 * time.Hour)) {
|
||||
t.Fatalf("the outcome lives until %v", u)
|
||||
}
|
||||
}
|
||||
|
||||
// memLog is a log of the bus as the runtime answers it, in memory; full, it refuses an entry.
|
||||
type memLog struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
entries []stdio.LogEntry
|
||||
full bool
|
||||
}
|
||||
|
||||
func newMemLog(now func() time.Time) *memLog { return &memLog{now: now} }
|
||||
|
||||
func (l *memLog) Append(key string, value any) (uint64, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.full {
|
||||
return 0, errors.New("the log is full: maximum bytes exceeded")
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
seq := uint64(len(l.entries) + 1)
|
||||
l.entries = append(l.entries, stdio.LogEntry{Sequence: seq, Key: key, Value: raw, At: l.now()})
|
||||
return seq, nil
|
||||
}
|
||||
|
||||
func (l *memLog) Read(from uint64, keys string, limit int) (*stdio.LogPage, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
p := &stdio.LogPage{Entries: []stdio.LogEntry{}, Last: uint64(len(l.entries))}
|
||||
for _, e := range l.entries {
|
||||
if e.Sequence >= from && (keys == "" || e.Key == keys) {
|
||||
if limit > 0 && len(p.Entries) == limit {
|
||||
p.Next = e.Sequence
|
||||
break
|
||||
}
|
||||
p.Entries = append(p.Entries, e)
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (l *memLog) Last(key string) (*stdio.LogEntry, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for i := len(l.entries) - 1; i >= 0; i-- {
|
||||
if l.entries[i].Key == key {
|
||||
e := l.entries[i]
|
||||
return &e, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (l *memLog) Info() (*stdio.LogInfo, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
i := &stdio.LogInfo{Entries: uint64(len(l.entries)), Last: uint64(len(l.entries))}
|
||||
if len(l.entries) > 0 {
|
||||
i.First = 1
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (l *memLog) Newest(keys string) ([]stdio.LogEntry, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
last := map[string]int{}
|
||||
for i, e := range l.entries {
|
||||
if keys == "" || e.Key == keys {
|
||||
last[e.Key] = i
|
||||
}
|
||||
}
|
||||
out := []stdio.LogEntry{}
|
||||
for i, e := range l.entries {
|
||||
if j, ok := last[e.Key]; ok && j == i {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// under is every entry of a log's subject, decoded.
|
||||
func (l *memLog) under(key string) []map[string]any {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
var out []map[string]any
|
||||
for _, e := range l.entries {
|
||||
if key == "" || e.Key == key {
|
||||
var v map[string]any
|
||||
_ = json.Unmarshal(e.Value, &v)
|
||||
v["what"] = e.Key
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §6: a reading appends one entry under its licence's key, the reading beside the raw answer.
|
||||
func TestAReadingAppendsOneUsageEntry(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := mm.usage.under(KeyFor(licence1))
|
||||
if len(got) != 1 || len(mm.usage.entries) != 1 {
|
||||
t.Fatalf("a reading appended %v", mm.usage.entries)
|
||||
}
|
||||
if got[0]["raw"] == nil || got[0]["reading"] == nil || got[0]["at"] == nil {
|
||||
t.Fatalf("the entry %v", got[0])
|
||||
}
|
||||
rows, _ := mm.store.Usage(ctx, licence1, time.Time{}, 20, false)
|
||||
if len(rows) != 1 || rows[0].Licence != licence1 || rows[0].Reading.SessionPct == nil {
|
||||
t.Fatalf("usage answers %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §7, B20: each act appends one entry under its kind; a full log fails the act loudly.
|
||||
func TestEachActAppendsOneAuditEntry(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
_, _ = mm.m.Rotate(ctx, licence1, true)
|
||||
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
|
||||
_, _ = mm.m.Bind(ctx, "laptop", "api")
|
||||
_, _ = mm.m.Release(ctx, "laptop")
|
||||
for kind, n := range map[string]int{"adopted": 1, "bound": 1, "rotated": 1, "switched": 1, "released": 1} {
|
||||
if got := len(mm.audit.under(kind)); got != n {
|
||||
t.Errorf("%s: %d entries, want %d", kind, got, n)
|
||||
}
|
||||
}
|
||||
for _, e := range mm.audit.under("") {
|
||||
if e["at"] == nil || e["by"] == nil && e["what"] != "adopted" {
|
||||
t.Errorf("the entry %v has no at or by", e)
|
||||
}
|
||||
}
|
||||
mm.audit.full = true
|
||||
if _, err := mm.m.Bind(ctx, "laptop", "api"); err == nil || !strings.Contains(err.Error(), "the audit did not take") {
|
||||
t.Fatalf("a bind with a full audit log: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package main
|
||||
|
||||
// The manager's conditions (design 39 bus-and-switch §9, §11, §29 to §31, §33, B17, B37 to B39): each said on the
|
||||
// operator channel as any module says one (novox/hq ADR 0437 §9), the router alone deciding whether and where the
|
||||
// operator is told, and each it said raised kept in its state `conditions`, written before it is said and deleted when
|
||||
// it is said cleared. A raise or a clear is said again only when what it says changes (ADR 0289 §1); at its start the
|
||||
// manager judges every kind anew, says raised again each it still holds and cleared each key it no longer holds.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// HeadlineLength is the operator channel's limit on a headline; a longer one is cut, ending in "…" (B39).
|
||||
const HeadlineLength = 60
|
||||
|
||||
// SayCondition publishes a condition on the operator channel's condition accept, under the manager's name, in the form
|
||||
// its client encodes (ADR 0437 §9).
|
||||
func SayCondition(c oc.Condition) error {
|
||||
subject, err := occ.ConditionSubject(Publisher)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := occ.EncodeCondition(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return stdio.SeatPublish(subject, json.RawMessage(raw), "")
|
||||
}
|
||||
|
||||
// condition is a raised condition of the manager: its key, severity, words and the line said when it is over. An
|
||||
// empty needs is "Nothing for you to do."; the explanation is opened by its verdict here.
|
||||
func condition(key, severity, headline, summary, needs, explanation, resolved string) oc.Condition {
|
||||
verdict := occ.NothingToDo
|
||||
if needs != "" {
|
||||
verdict = occ.NeedsYou + needs + "."
|
||||
}
|
||||
if explanation != "" {
|
||||
verdict += " " + explanation
|
||||
}
|
||||
return oc.Condition{Key: key, Severity: severity, State: occ.StateRaised,
|
||||
Subject: oc.ConditionSubject{Scope: "module", ID: Publisher},
|
||||
Headline: cut(headline), Summary: summary, Needs: needs, Explanation: verdict, Resolved: resolved}
|
||||
}
|
||||
|
||||
// cut keeps a headline within the operator channel's limit, ending a longer one in "…" (B39).
|
||||
func cut(headline string) string {
|
||||
r := []rune(headline)
|
||||
if len(r) <= HeadlineLength {
|
||||
return headline
|
||||
}
|
||||
return string(r[:HeadlineLength-1]) + "…"
|
||||
}
|
||||
|
||||
// short is a licence's short name for a headline: its address before the "@", or its name (B39).
|
||||
func short(name string) string {
|
||||
if at := strings.Index(name, "@"); at > 0 {
|
||||
return name[:at]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// machines is a count of consumers in a headline (B39).
|
||||
func machines(n int) string {
|
||||
if n == 1 {
|
||||
return "1 machine"
|
||||
}
|
||||
return fmt.Sprintf("%d machines", n)
|
||||
}
|
||||
|
||||
// Judge is every condition the manager holds now, judged anew from its own state and readings, by key.
|
||||
func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) {
|
||||
held := map[string]oc.Condition{}
|
||||
s := m.settings()
|
||||
reasons := s.Reasons()
|
||||
for _, o := range s.OutOfBound {
|
||||
bound := fmt.Sprintf("at least %g", o.Min)
|
||||
if o.HasMax {
|
||||
bound = fmt.Sprintf("between %g and %g", o.Min, o.Max)
|
||||
}
|
||||
key := "settings." + o.Setting + ".invalid"
|
||||
held[key] = condition(key, occ.SeverityWarning, o.Setting+" is outside its bound",
|
||||
reasons[o.Setting],
|
||||
fmt.Sprintf("set %s %s", o.Setting, bound),
|
||||
fmt.Sprintf("%s is set to %g; the licence manager uses its default %g until it is fixed.", o.Setting,
|
||||
o.Value, o.Default), o.Setting+" is within its bound again")
|
||||
}
|
||||
for _, p := range s.Invalid() {
|
||||
key := "settings." + p.Setting + ".invalid"
|
||||
if _, ok := held[key]; ok {
|
||||
continue
|
||||
}
|
||||
held[key] = condition(key, occ.SeverityWarning, p.Setting+" is above switch_at_pct",
|
||||
reasons[p.Setting],
|
||||
fmt.Sprintf("set %s at or below switch_at_pct (%g)", p.Setting, p.SwitchAt),
|
||||
fmt.Sprintf("%s is %g, above switch_at_pct %g; the licence manager moves no consumer until it is fixed.",
|
||||
p.Setting, p.Value, p.SwitchAt), p.Setting+" is at or below switch_at_pct again")
|
||||
}
|
||||
licences, err := m.Store.Licences(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindings, err := m.Store.Bindings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range licences {
|
||||
for key, c := range m.judgeLicence(l, licences, bindings, s) {
|
||||
held[key] = c
|
||||
}
|
||||
}
|
||||
return held, nil
|
||||
}
|
||||
|
||||
// judgeLicence is what licence l holds of no-room (§29), near-limit (§30) and usage-unread (§31).
|
||||
func (m *Manager) judgeLicence(l Licence, licences []Licence, bindings []Binding, s Settings) map[string]oc.Condition {
|
||||
held := map[string]oc.Condition{}
|
||||
key := "licence." + KeyFor(l.Name) + "."
|
||||
consumers := consumersOf(l.Name, bindings)
|
||||
list := strings.Join(consumers, ", ")
|
||||
if r := m.RuleFor(l, licences, bindings, s); r.NoTarget {
|
||||
o := r.Over
|
||||
var rooms []string
|
||||
for _, other := range licences {
|
||||
if other.Name != l.Name {
|
||||
rooms = append(rooms, other.Name+" "+m.roomWords(other, s))
|
||||
}
|
||||
}
|
||||
sort.Strings(rooms)
|
||||
others := "no other licence is held"
|
||||
if len(rooms) > 0 {
|
||||
others = strings.Join(rooms, "; ")
|
||||
}
|
||||
held[key+"no-room"] = condition(key+"no-room", occ.SeverityUrgent,
|
||||
"No licence has room for "+machines(len(consumers)),
|
||||
fmt.Sprintf("no licence has room for %s, bound to %s at %g %% of its %s window", list, l.Name, o.Pct, o.Window),
|
||||
fmt.Sprintf("add a licence, or wait for %s's %s window to reset at %s", l.Name, o.Window, o.ResetsAt),
|
||||
fmt.Sprintf("%s is at %g %% of its %s window, with %s bound to it; %s.", l.Name, o.Pct, o.Window, list, others),
|
||||
list+" have a licence with room again")
|
||||
}
|
||||
if window, pct, resetsAt, ok := windowAt(l.Usage, s.WarnAt); ok && len(consumers) > 0 {
|
||||
needs, plan := "", ""
|
||||
if to, _ := m.targetFor(l, licences, s); to != "" {
|
||||
plan = fmt.Sprintf("%s move to %s at %g %%. ", list, to, s.SwitchAt)
|
||||
} else {
|
||||
needs = "no other licence has room"
|
||||
}
|
||||
held[key+"near-limit"] = condition(key+"near-limit", occ.SeverityWarning,
|
||||
fmt.Sprintf("%s at %g%% %s", short(l.Name), pct, window),
|
||||
fmt.Sprintf("%s at %g %% of its %s window", l.Name, pct, window), needs,
|
||||
fmt.Sprintf("%s%s is at %g %% of its %s window, which resets at %s; %s bound to it.", plan, l.Name, pct,
|
||||
window, resetsAt, list), fmt.Sprintf("%s is below %g %% again", l.Name, s.WarnAt))
|
||||
}
|
||||
if since, stale := m.unread(l, s); stale {
|
||||
needs := ""
|
||||
if len(consumers) > 0 {
|
||||
needs = list + " are not moved off it while its usage is unknown"
|
||||
}
|
||||
why := l.Usage.ReadErrorWords()
|
||||
held[key+"usage-unread"] = condition(key+"usage-unread", occ.SeverityWarning,
|
||||
short(l.Name)+"'s usage cannot be read",
|
||||
fmt.Sprintf("the usage of %s cannot be read: %s", l.Name, why), needs,
|
||||
fmt.Sprintf("%s, older than %g minutes; the vendor answers: %s.", since, s.UsageStale.Minutes(), why),
|
||||
l.Name+"'s usage is read again")
|
||||
}
|
||||
return held
|
||||
}
|
||||
|
||||
// unread is whether subscription l's latest successful reading is older than usage_stale_minutes (§31), or, never
|
||||
// read successfully, its adoption is (B38); and the words naming since when.
|
||||
func (m *Manager) unread(l Licence, s Settings) (string, bool) {
|
||||
if l.Kind != "subscription" {
|
||||
return "", false
|
||||
}
|
||||
if l.Usage != nil && l.Usage.ReadAt != "" {
|
||||
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
|
||||
if err != nil {
|
||||
return "its latest successful reading has no time", true
|
||||
}
|
||||
return "its latest successful reading was at " + l.Usage.ReadAt, m.Now().Sub(read) > s.UsageStale
|
||||
}
|
||||
if l.AdoptedAt == 0 {
|
||||
return "", false
|
||||
}
|
||||
adopted := time.UnixMilli(l.AdoptedAt)
|
||||
return "no reading has succeeded since its adoption at " + adopted.UTC().Format(time.RFC3339),
|
||||
m.Now().Sub(adopted) > s.UsageStale
|
||||
}
|
||||
|
||||
// roomWords is another licence's room for the condition no-room, or why it is not known or not enough (§29).
|
||||
func (m *Manager) roomWords(o Licence, s Settings) string {
|
||||
room, known := Room(o, m.Now(), s)
|
||||
switch {
|
||||
case known && *o.Usage.WeeklyPct >= s.RoomBelow:
|
||||
return fmt.Sprintf("has %g %% room, its weekly window not below %g %%", room, s.RoomBelow)
|
||||
case known:
|
||||
return fmt.Sprintf("has %g %% room", room)
|
||||
case o.Kind != "subscription":
|
||||
return "is an API key, whose room is never known"
|
||||
case o.Usage == nil || o.Usage.ReadAt == "":
|
||||
return "has no reading"
|
||||
case o.Usage.WeeklyPct == nil:
|
||||
return "has no weekly window in its reading"
|
||||
case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()):
|
||||
return "does not refresh"
|
||||
}
|
||||
return fmt.Sprintf("was not read within %g minutes", s.UsageStale.Minutes())
|
||||
}
|
||||
|
||||
// SayConditions judges every kind anew and says what changed: each condition held whose words differ from those kept
|
||||
// is written in `conditions`, then said raised; each kept key no longer held is said cleared, then deleted. With
|
||||
// again (at the start, and by licences_resync), each held is said raised again whether or not it changed.
|
||||
func (m *Manager) SayConditions(ctx context.Context, again bool) error {
|
||||
if m.Conditions == nil || m.Say == nil {
|
||||
return nil
|
||||
}
|
||||
m.conditionsMu.Lock()
|
||||
defer m.conditionsMu.Unlock()
|
||||
held, err := m.Judge(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := m.Conditions.Keys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []error
|
||||
for _, key := range keys {
|
||||
if _, ok := held[key]; ok {
|
||||
continue
|
||||
}
|
||||
var kept KeptCondition
|
||||
e, err := m.Conditions.Get(key)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
if e != nil && len(e.Value) > 0 {
|
||||
_ = json.Unmarshal(e.Value, &kept)
|
||||
}
|
||||
cleared := kept.Body
|
||||
cleared.Key, cleared.State = key, occ.StateCleared
|
||||
cleared.Subject = oc.ConditionSubject{Scope: "module", ID: Publisher}
|
||||
if err := m.Say(cleared); err != nil {
|
||||
errs = append(errs, fmt.Errorf("the clearing of %s could not be said: %w", key, err))
|
||||
continue
|
||||
}
|
||||
if err := m.Conditions.Delete(key); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
var order []string
|
||||
for key := range held {
|
||||
order = append(order, key)
|
||||
}
|
||||
sort.Strings(order)
|
||||
for _, key := range order {
|
||||
c := held[key]
|
||||
kept := KeptCondition{Body: c, Since: m.Now().UTC().Format(time.RFC3339)}
|
||||
if e, err := m.Conditions.Get(key); err == nil && e != nil && len(e.Value) > 0 {
|
||||
var was KeptCondition
|
||||
if json.Unmarshal(e.Value, &was) == nil {
|
||||
if !again && sameCondition(was.Body, c) {
|
||||
continue
|
||||
}
|
||||
if was.Since != "" {
|
||||
kept.Since = was.Since
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := m.Conditions.Put(key, kept); err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
if err := m.Say(c); err != nil {
|
||||
// Not said: the key goes again, so the next judgement says it.
|
||||
_ = m.Conditions.Delete(key)
|
||||
errs = append(errs, fmt.Errorf("%s could not be said: %w", key, err))
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("conditions: %v", errs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sameCondition is whether two raised conditions say the same.
|
||||
func sameCondition(a, b oc.Condition) bool {
|
||||
ra, _ := json.Marshal(a)
|
||||
rb, _ := json.Marshal(b)
|
||||
return string(ra) == string(rb)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// saying gives mm's manager a state `conditions` and records each condition it says, in the form the operator
|
||||
// channel's client encodes, refusing what it refuses.
|
||||
func saying(mm *miniMesh) (*memState, *[]oc.Condition) {
|
||||
kept := newMemState(func() time.Time { return mm.now })
|
||||
said := &[]oc.Condition{}
|
||||
mm.m.Conditions = kept
|
||||
mm.m.Say = func(c oc.Condition) error {
|
||||
if _, err := occ.EncodeCondition(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := occ.ConditionSubject(Publisher); err != nil {
|
||||
return err
|
||||
}
|
||||
*said = append(*said, c)
|
||||
return nil
|
||||
}
|
||||
return kept, said
|
||||
}
|
||||
|
||||
// of is what was said of key, as "state" per saying.
|
||||
func of(said []oc.Condition, key string) []string {
|
||||
var out []string
|
||||
for _, c := range said {
|
||||
if c.Key == key {
|
||||
out = append(out, c.State)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §9/AC1, §11, §33, B17, B37: each kind of condition the manager holds of itself is written in
|
||||
// `conditions` with its body and since, said once with its key, severity, scope and id, its words opened by their
|
||||
// verdict, and said cleared, its key deleted, once it no longer holds.
|
||||
func TestAConditionIsKeptThenSaidOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
file := filepath.Join(t.TempDir(), "settings.json")
|
||||
for _, c := range []struct {
|
||||
name, key, severity string
|
||||
raise, fix func(mm *miniMesh)
|
||||
}{
|
||||
{"an invalid pair", "settings.room_below_pct.invalid", occ.SeverityWarning,
|
||||
func(mm *miniMesh) { mm.m.Settings.RoomBelow = 100 }, func(mm *miniMesh) { mm.m.Settings.RoomBelow = 90 }},
|
||||
{"a setting outside its bound", "settings.switch_at_pct.invalid", occ.SeverityWarning,
|
||||
func(mm *miniMesh) {
|
||||
_ = os.WriteFile(file, []byte(`{"switch_at_pct": 150}`), 0o600)
|
||||
mm.m.Settings = SettingsFrom(file)
|
||||
},
|
||||
func(mm *miniMesh) {
|
||||
_ = os.WriteFile(file, []byte(`{"switch_at_pct": 98}`), 0o600)
|
||||
mm.m.Settings = SettingsFrom(file)
|
||||
}},
|
||||
} {
|
||||
mm := newMesh(t)
|
||||
kept, said := saying(mm)
|
||||
c.raise(mm)
|
||||
for range 2 {
|
||||
if err := mm.m.SayConditions(ctx, false); err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
if got := of(*said, c.key); strings.Join(got, ",") != "raised" {
|
||||
t.Fatalf("%s: said %v of %s, all said %+v", c.name, got, c.key, *said)
|
||||
}
|
||||
s := (*said)[0]
|
||||
if s.Severity != c.severity || s.Subject.Scope != "module" || s.Subject.ID != Publisher || s.Headline == "" ||
|
||||
s.Summary == "" || s.Resolved == "" || !strings.HasPrefix(s.Explanation, occ.NeedsYou+s.Needs) || s.Needs == "" {
|
||||
t.Fatalf("%s: said %+v", c.name, s)
|
||||
}
|
||||
var k KeptCondition
|
||||
if e, _ := kept.Get(c.key); e == nil || json.Unmarshal(e.Value, &k) != nil || k.Body.Key != c.key || k.Since == "" {
|
||||
t.Fatalf("%s: kept %+v", c.name, k)
|
||||
}
|
||||
c.fix(mm)
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
if got := of(*said, c.key); strings.Join(got, ",") != "raised,cleared" {
|
||||
t.Fatalf("%s: said %v", c.name, got)
|
||||
}
|
||||
if e, _ := kept.Get(c.key); e != nil {
|
||||
t.Fatalf("%s: the key is kept after its clearing", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §9/AC2, design 39 parts §11: at its start the manager says cleared each key of `conditions` it no longer holds,
|
||||
// and says raised again each it still holds, unchanged or not.
|
||||
func TestAStartClearsWhatItNoLongerHolds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mm := newMesh(t)
|
||||
kept, said := saying(mm)
|
||||
gone := condition("licence.gone.no-room", occ.SeverityUrgent, "No licence has room for 1 machine", "s", "add one",
|
||||
"", "x has a licence with room again")
|
||||
mm.m.Settings.WarnAt = 100
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
_, _ = kept.Put(gone.Key, KeptCondition{Body: gone, Since: "2026-11-11T20:00:00Z"})
|
||||
// design 39 parts §11: a carry-over-failed key kept from before the carry-over left is cleared like any other.
|
||||
carry := condition("carry-over-failed", occ.SeverityUrgent, "The licence manager could not carry over its licences",
|
||||
"s", "", "", "The licence manager carried over its licences")
|
||||
_, _ = kept.Put(carry.Key, KeptCondition{Body: carry, Since: "2026-11-12T09:00:00Z"})
|
||||
*said = nil
|
||||
if err := mm.m.SayConditions(ctx, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(of(*said, gone.Key), ",") != "cleared" || strings.Join(of(*said, carry.Key), ",") != "cleared" ||
|
||||
strings.Join(of(*said, "settings.warn_at_pct.invalid"), ",") != "raised" {
|
||||
t.Fatalf("a start said %+v", *said)
|
||||
}
|
||||
if (*said)[0].Resolved != carry.Resolved || (*said)[1].Resolved != gone.Resolved {
|
||||
t.Fatalf("the clearings carry %+v", *said)
|
||||
}
|
||||
for _, key := range []string{gone.Key, carry.Key} {
|
||||
if e, _ := kept.Get(key); e != nil {
|
||||
t.Fatalf("%s, no longer held, is kept", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §29/AC1, B39: with every licence at its threshold, no-room is said once, its headline a
|
||||
// count of machines, its words naming the licence, the window's reset and every other licence's room; a reading with
|
||||
// room clears it.
|
||||
func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mm := fourOnA(t)
|
||||
_, said := saying(mm)
|
||||
_ = mm.store.SaveLicence(ctx, sub("b", read(20, 95)))
|
||||
_ = mm.store.SaveLicence(ctx, sub("c", read(40, 99)))
|
||||
for range 2 {
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
}
|
||||
key := "licence.a.no-room"
|
||||
if strings.Join(of(*said, key), ",") != "raised" {
|
||||
t.Fatalf("said %+v", *said)
|
||||
}
|
||||
var c oc.Condition
|
||||
for _, s := range *said {
|
||||
if s.Key == key {
|
||||
c = s
|
||||
}
|
||||
}
|
||||
if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" ||
|
||||
c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" ||
|
||||
!strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") ||
|
||||
!strings.Contains(c.Explanation, "b has 5 % room, its weekly window not below 90 %") ||
|
||||
c.Resolved != "ace, g14, laptop, server have a licence with room again" {
|
||||
t.Fatalf("no-room %+v", c)
|
||||
}
|
||||
_ = mm.store.SaveLicence(ctx, sub("b", read(10, 20)))
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
if strings.Join(of(*said, key), ",") != "raised,cleared" {
|
||||
t.Fatalf("said %v after a reading with room", of(*said, key))
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §30/AC1, B39: a rising series of readings says near-limit once at the crossing and again only
|
||||
// when its words change; with no other licence having room it needs the operator; a reading after the reset clears it.
|
||||
func TestNearLimitIsSaidOncePerCrossing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mm := fourOnA(t)
|
||||
_, said := saying(mm)
|
||||
key := "licence.a.near-limit"
|
||||
var last oc.Condition
|
||||
for _, w := range []float64{80, 89, 91, 91, 95} {
|
||||
_ = mm.store.SaveLicence(ctx, sub("a", read(95, w))) // a session at 95 % takes no part (C4)
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
for _, s := range *said {
|
||||
if s.Key == key {
|
||||
last = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Join(of(*said, key), ",") != "raised,raised" || last.Headline != "a at 95% weekly" || last.Needs != "" ||
|
||||
!strings.HasPrefix(last.Explanation, occ.NothingToDo+" ace, g14, laptop, server move to b at 99 %.") ||
|
||||
!strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") {
|
||||
t.Fatalf("said %v, last %+v", of(*said, key), last)
|
||||
}
|
||||
_ = mm.store.SaveLicence(ctx, sub("b", read(0, 95)))
|
||||
_ = mm.store.SaveLicence(ctx, sub("c", read(0, 95)))
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" {
|
||||
t.Fatalf("with no target said %+v", c)
|
||||
}
|
||||
_ = mm.store.SaveLicence(ctx, sub("a", read(3, 1)))
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
if got := of(*said, key); got[len(got)-1] != "cleared" || len(got) != 4 {
|
||||
t.Fatalf("after the reset said %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §31/AC1, B34, B38: three failed readings in a row, the latest success then older than
|
||||
// usage_stale_minutes, say usage-unread naming the vendor's reason, needing the operator for the consumers bound to it;
|
||||
// one success clears it. A subscription never read successfully is said once its adoption is that old.
|
||||
func TestUsageUnreadIsSaidAndCleared(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mm := newMesh(t)
|
||||
_, said := saying(mm)
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := "licence." + KeyFor(licence1) + ".usage-unread"
|
||||
mm.vendor.usageErr = errors.New("429 Too Many Requests: rate limited")
|
||||
for i := 1; i <= 3; i++ {
|
||||
mm.now = t0.Add(time.Duration(i) * 6 * time.Minute)
|
||||
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(of(*said, key)); (i < 3 && got != 0) || (i == 3 && got != 1) {
|
||||
t.Fatalf("after %d failed readings said %v", i, of(*said, key))
|
||||
}
|
||||
}
|
||||
c := (*said)[len(*said)-1]
|
||||
if c.Severity != occ.SeverityWarning || c.Needs != "laptop are not moved off it while its usage is unknown" ||
|
||||
!strings.Contains(c.Explanation, "the vendor answers: 429 Too Many Requests: rate limited") {
|
||||
t.Fatalf("usage-unread %+v", c)
|
||||
}
|
||||
mm.vendor.usageErr = nil
|
||||
_ = mm.m.ReadUsage(ctx)
|
||||
if strings.Join(of(*said, key), ",") != "raised,cleared" {
|
||||
t.Fatalf("after a success said %v", of(*said, key))
|
||||
}
|
||||
// B38: never read successfully since its adoption.
|
||||
never := Licence{Name: "new@example.org", Kind: "subscription", AdoptedAt: mm.now.UnixMilli()}
|
||||
_ = mm.store.SaveLicence(ctx, never)
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
newKey := "licence." + KeyFor(never.Name) + ".usage-unread"
|
||||
if len(of(*said, newKey)) != 0 {
|
||||
t.Fatal("said at its adoption")
|
||||
}
|
||||
mm.now = mm.now.Add(16 * time.Minute)
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
if strings.Join(of(*said, newKey), ",") != "raised" {
|
||||
t.Fatalf("said %v of a licence never read", of(*said, newKey))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
// What every node holds, heard as events (design 39 bus-and-switch §23, §46, B12): the agent module says
|
||||
// `holdings-changed` with its holdings report whole, at its start, on every change and when asked; the manager says
|
||||
// `holdings.asked` at every start, after its conditions are judged, and on its resync, and takes each report it hears as that
|
||||
// node's latest.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HoldingsChanged is the agent module's event saying what one node holds, which the manager consumes.
|
||||
const HoldingsChanged = "claude-code.holdings-changed"
|
||||
|
||||
// HoldingsAsked is the manager's event asking every node to say what it holds again.
|
||||
const HoldingsAsked = "holdings.asked"
|
||||
|
||||
// Heard is what every node last said it holds, by node.
|
||||
type Heard struct {
|
||||
mu sync.Mutex
|
||||
reports map[string]Holdings
|
||||
}
|
||||
|
||||
// Take keeps one `holdings-changed` as its node's latest report.
|
||||
func (h *Heard) Take(body []byte) (Holdings, error) {
|
||||
var r Holdings
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return r, fmt.Errorf("%s is not a holdings report: %w", HoldingsChanged, err)
|
||||
}
|
||||
if r.Node == "" {
|
||||
return r, fmt.Errorf("%s names no node", HoldingsChanged)
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.reports == nil {
|
||||
h.reports = map[string]Holdings{}
|
||||
}
|
||||
h.reports[r.Node] = r
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// All is every node's latest report, by node's name.
|
||||
func (h *Heard) All() []Holdings {
|
||||
h.mu.Lock()
|
||||
all := make([]Holdings, 0, len(h.reports))
|
||||
for _, r := range h.reports {
|
||||
all = append(all, r)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
|
||||
return all
|
||||
}
|
||||
|
||||
// Of is the generation node last reported applying.
|
||||
func (h *Heard) Of(node string) int64 {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.reports[node].Generation
|
||||
}
|
||||
|
||||
// AskHoldings says `holdings.asked`, its time and who asks (§23).
|
||||
func (m *Manager) AskHoldings(by string) error {
|
||||
return m.Emit(HoldingsAsked, map[string]any{"at": m.Now().UTC().Format(time.RFC3339Nano), "by": by})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
// A licence's key taken by another (design 39 bus-and-switch §10, B6); the key form itself is the shared package's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// keyTaken names the licence other than name whose key is name's, or "" when none is.
|
||||
func (m *Manager) keyTaken(ctx context.Context, name string) (string, error) {
|
||||
all, err := m.Store.Licences(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := KeyFor(name)
|
||||
for _, l := range all {
|
||||
if l.Name != name && KeyFor(l.Name) == key {
|
||||
return l.Name, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// design 39 bus-and-switch §10: an adoption whose key another licence has is refused, in the audit and as an event.
|
||||
func TestAnAdoptionWhoseKeyIsTakenIsRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
t.Run("an API key", func(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
_ = mm.store.SaveLicence(ctx, Licence{Name: "Ops-Key", Kind: "api-key"})
|
||||
_, err := mm.m.adoptKey(ctx, "ops-key", "sk-ant-"+strings.Repeat("a", 40), "a test")
|
||||
if err == nil || !strings.Contains(err.Error(), "Ops-Key") {
|
||||
t.Fatalf("adopted a key whose key form is taken: %v", err)
|
||||
}
|
||||
if l, _ := mm.store.Licence(ctx, "ops-key"); l != nil {
|
||||
t.Fatal("the clashing licence was kept")
|
||||
}
|
||||
assertRefused(t, mm)
|
||||
})
|
||||
t.Run("a login", func(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
_ = mm.store.SaveLicence(ctx, Licence{Name: "ACCT-1@example.org", Kind: "api-key"})
|
||||
r := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute))
|
||||
adopted, err := mm.m.Consider(ctx, []Holdings{r})
|
||||
if err != nil || len(adopted) != 0 {
|
||||
t.Fatalf("adopted %v (%v), want none", adopted, err)
|
||||
}
|
||||
if len(mm.vendor.exchanged) != 0 {
|
||||
t.Fatalf("a refused login was exchanged: %v", mm.vendor.exchanged)
|
||||
}
|
||||
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-laptop")); o != Refused {
|
||||
t.Fatalf("outcome %q, want refused", o)
|
||||
}
|
||||
assertRefused(t, mm)
|
||||
})
|
||||
}
|
||||
|
||||
func assertRefused(t *testing.T, mm *miniMesh) {
|
||||
t.Helper()
|
||||
said := false
|
||||
for _, e := range mm.events {
|
||||
said = said || strings.HasPrefix(e, "licence.refused ")
|
||||
}
|
||||
audited := false
|
||||
for _, a := range mm.audit.under("") {
|
||||
audited = audited || a["what"] == "refused"
|
||||
}
|
||||
if !said || !audited {
|
||||
t.Fatalf("refusal said %v, audited %v: %v", said, audited, mm.events)
|
||||
}
|
||||
}
|
||||
+161
-112
@@ -1,10 +1,10 @@
|
||||
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control
|
||||
// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the
|
||||
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
|
||||
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
|
||||
//
|
||||
// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the
|
||||
// version that needs it, with the module's words and no bus, and it brings the store's schema to shape.
|
||||
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): the licence manager's manager bundle (design 39
|
||||
// parts §2), loaded by the module's runtime of its own under its own account (`runs-as claude-licence-manager`, design
|
||||
// 39 bus-and-switch C1), never by the machine's tool runner, and speaking MCP to it over stdio through the Go SDK (ADR
|
||||
// 0193, ADR 0198). It runs long (the conditions judged at its start, the usage timer, the switch rule): it watches what
|
||||
// every node reports holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads
|
||||
// usage. It answers the seat's ten verbs and the module's three acting tools, and is the only part that writes the
|
||||
// module's kept data and logs; the reading tools are the tools bundle's (cmd/tools, parts §3, §4).
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
@@ -16,12 +16,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
@@ -32,14 +33,6 @@ func say(format string, args ...any) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "prepare" {
|
||||
if err := prepare(); err != nil {
|
||||
say("preparing the store failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
say("the store's schema is what this version needs")
|
||||
return
|
||||
}
|
||||
go daemon()
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
say("%v", err)
|
||||
@@ -47,17 +40,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func prepare() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
store, err := PgStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
return store.Migrate(ctx)
|
||||
}
|
||||
|
||||
// ---- what the binary is handed -----------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
@@ -66,26 +48,18 @@ var (
|
||||
)
|
||||
|
||||
// manager builds the rules' dependencies once, from the module's words (ADR 0192): file paths, never
|
||||
// values. A failure is said and tried again on the next call, so a database that arrives late is not fatal.
|
||||
// values. A failure is said and tried again on the next call, so a key pair that cannot be made yet is not fatal.
|
||||
func manager() (*Manager, error) {
|
||||
buildMu.Lock()
|
||||
defer buildMu.Unlock()
|
||||
if built != nil {
|
||||
return built, nil
|
||||
}
|
||||
dir, keyFile := os.Getenv("MESH_LICENCE_STATE"), os.Getenv("MESH_LICENCE_KEY_FILE")
|
||||
if dir == "" || keyFile == "" {
|
||||
return nil, errors.New("MESH_LICENCE_STATE and MESH_LICENCE_KEY_FILE are not set: the mesh renders them for this module")
|
||||
dir := os.Getenv("MESH_LICENCE_STATE")
|
||||
if dir == "" {
|
||||
return nil, errors.New("MESH_LICENCE_STATE is not set: the mesh renders it for this module")
|
||||
}
|
||||
crypt, err := CryptFromFile(keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := keypair(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := PgStoreFromEnv(context.Background())
|
||||
keys, err := keypair(dir, say)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,11 +67,12 @@ func manager() (*Manager, error) {
|
||||
if n := os.Getenv("MESH_NODE"); n != "" {
|
||||
node = n
|
||||
}
|
||||
bindings := stdio.State("bindings")
|
||||
built = &Manager{
|
||||
Store: store,
|
||||
Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"),
|
||||
LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"),
|
||||
AuditLog: stdio.Log("audit"), Now: time.Now},
|
||||
Vendor: LiveVendor(),
|
||||
Crypt: crypt,
|
||||
Grants: VaultGrants{Keys: keys},
|
||||
Keys: keys,
|
||||
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||
var a GrantAnswer
|
||||
@@ -107,50 +82,103 @@ func manager() (*Manager, error) {
|
||||
}
|
||||
return a, json.Unmarshal(raw, &a)
|
||||
},
|
||||
PutBinding: func(_ context.Context, consumer string, b BindingState) error {
|
||||
_, err := bindings.Put(consumer, b)
|
||||
Reported: heard.Of,
|
||||
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
|
||||
Tell: func(_ context.Context, headline string, lines []string) error {
|
||||
_, err := stdio.Ask("seat:operator-channel.tell", map[string]any{"subject": Seat, "headline": headline,
|
||||
"lines": lines})
|
||||
return err
|
||||
},
|
||||
DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) },
|
||||
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
|
||||
Now: time.Now,
|
||||
Log: say,
|
||||
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
|
||||
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
|
||||
Now: time.Now,
|
||||
Log: say,
|
||||
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
|
||||
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
|
||||
SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"),
|
||||
Conditions: stdio.State("conditions"),
|
||||
Say: SayCondition,
|
||||
}
|
||||
return built, nil
|
||||
}
|
||||
|
||||
// keypair is this module's own, made once in its state directory; the private half never leaves it.
|
||||
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
|
||||
func keypair(dir string) (KeyPair, error) {
|
||||
// Written whole, then linked into place, so a second process reads the first's key and never half of it. A pair it
|
||||
// finds and cannot read (another account's, left before the module ran as its own) is made anew in its place and said
|
||||
// in its journal (design 39 bus-and-switch C2): every use passes the public key per ask, so nothing depends on the old.
|
||||
func keypair(dir string, log func(string, ...any)) (KeyPair, error) {
|
||||
file := filepath.Join(dir, "manager-key.json")
|
||||
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
|
||||
k, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
if err := writeKeyPair(dir, file, false); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
raw, _ := json.Marshal(k)
|
||||
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
_ = os.Link(tmp, file) // fails when another made it first, which is right
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
k, err := readKeyPair(file)
|
||||
if err == nil {
|
||||
return k, nil
|
||||
}
|
||||
log("the key pair %s cannot be read (%v); a new one is made in its place", file, err)
|
||||
if err := writeKeyPair(dir, file, true); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
return readKeyPair(file)
|
||||
}
|
||||
|
||||
func readKeyPair(file string) (KeyPair, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
var k KeyPair
|
||||
return k, json.Unmarshal(raw, &k)
|
||||
if err := json.Unmarshal(raw, &k); err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
if k.PublicKey == "" || k.PrivateKey == "" {
|
||||
return KeyPair{}, errors.New("it holds no whole key pair")
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// writeKeyPair writes a new pair whole beside file, then links it into place, or, replacing, renames it over the one
|
||||
// that cannot be read.
|
||||
func writeKeyPair(dir, file string, replace bool) error {
|
||||
k, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(k)
|
||||
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if replace {
|
||||
return os.Rename(tmp, file)
|
||||
}
|
||||
_ = os.Link(tmp, file) // fails when another made it first, which is right
|
||||
_ = os.Remove(tmp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the long-running half ---------------------------------------------------------------------------
|
||||
|
||||
// heard is what every node last said it holds (ADR 0206, design 39 bus-and-switch §23), by node.
|
||||
var heard = &Heard{}
|
||||
|
||||
// judgedAtStart judges every condition anew at the start, before the manager asks what the nodes hold and starts its
|
||||
// timers: raised again each it holds, cleared each key of `conditions` it no longer holds, carry-over-failed among
|
||||
// them when present (design 39 parts §11, bus-and-switch §9). It waits for nothing to be carried over.
|
||||
func judgedAtStart() {
|
||||
<-stdio.Served()
|
||||
m, err := manager()
|
||||
if err != nil {
|
||||
say("the conditions cannot be judged at the start: %v", err)
|
||||
return
|
||||
}
|
||||
if err := m.SayConditions(context.Background(), true); err != nil {
|
||||
say("saying the conditions at the start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func daemon() {
|
||||
var mu sync.Mutex
|
||||
reports := map[string]Holdings{}
|
||||
judgedAtStart()
|
||||
var passing sync.Mutex
|
||||
pass := func() {
|
||||
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
|
||||
@@ -160,14 +188,7 @@ func daemon() {
|
||||
say("not ready: %v", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
all := make([]Holdings, 0, len(reports))
|
||||
for _, r := range reports {
|
||||
all = append(all, r)
|
||||
}
|
||||
mu.Unlock()
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
|
||||
adopted, err := m.Consider(context.Background(), all)
|
||||
adopted, err := m.Consider(context.Background(), heard.All())
|
||||
if err != nil {
|
||||
say("considering the reports failed: %v", err)
|
||||
}
|
||||
@@ -176,48 +197,45 @@ func daemon() {
|
||||
}
|
||||
}
|
||||
|
||||
// What every node holds (ADR 0206): the whole current set, then each change. Asked again until it
|
||||
// answers — the channel to the runtime opens as the bundle starts, and the agent module's state may
|
||||
// arrive after this one.
|
||||
// What every node holds (ADR 0206, design 39 bus-and-switch §23): each `holdings-changed` heard is that node's
|
||||
// latest report and starts a pass; once heard, every node is asked to say it again (B12: at every start, after the
|
||||
// conditions are judged). Asked again until it answers — the channel to the runtime opens as the bundle starts.
|
||||
go func() {
|
||||
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
|
||||
for attempt := 0; ; attempt++ {
|
||||
err := stdio.State("claude-code.holdings").Watch("", func(c stdio.StateChange) error {
|
||||
mu.Lock()
|
||||
if c.Op == "put" {
|
||||
var h Holdings
|
||||
if json.Unmarshal(c.Value, &h) == nil {
|
||||
reports[c.Key] = h
|
||||
}
|
||||
} else {
|
||||
delete(reports, c.Key)
|
||||
}
|
||||
mu.Unlock()
|
||||
// During the current values the pass waits for the whole set: newest login first needs all.
|
||||
if !c.Current {
|
||||
go pass()
|
||||
err := stdio.Subscribe(HoldingsChanged, func(e stdio.Envelope) error {
|
||||
if _, err := heard.Take(e.Body); err != nil {
|
||||
say("%v", err)
|
||||
return nil
|
||||
}
|
||||
go pass()
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
mu.Lock()
|
||||
n := len(reports)
|
||||
mu.Unlock()
|
||||
say("watching what %d node(s) hold", n)
|
||||
pass()
|
||||
say("hearing what the nodes hold")
|
||||
if m, err := manager(); err != nil {
|
||||
say("not ready to ask what the nodes hold: %v", err)
|
||||
} else if err := m.AskHoldings(Itself); err != nil {
|
||||
say("asking what the nodes hold could not be said: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
pause := time.Minute
|
||||
if attempt < len(waits) {
|
||||
pause = waits[attempt]
|
||||
}
|
||||
say("what the nodes hold cannot be watched yet (%v); asking again in %s", err, pause)
|
||||
say("what the nodes hold cannot be heard yet (%v); asking again in %s", err, pause)
|
||||
time.Sleep(pause)
|
||||
}
|
||||
}()
|
||||
|
||||
refresh := time.NewTicker(time.Minute)
|
||||
usage := time.NewTicker(5 * time.Minute)
|
||||
// Every usage_minutes, read again at every reading (§24, §33): the manager's one timer of its own readings.
|
||||
every := Defaults.UsageEvery
|
||||
if m, err := manager(); err == nil {
|
||||
every = m.settings().UsageEvery
|
||||
}
|
||||
usage := time.NewTimer(every)
|
||||
for {
|
||||
select {
|
||||
case <-refresh.C:
|
||||
@@ -237,7 +255,9 @@ func daemon() {
|
||||
if err := m.ReadUsage(context.Background()); err != nil {
|
||||
say("reading usage failed: %v", err)
|
||||
}
|
||||
every = m.settings().UsageEvery
|
||||
}
|
||||
usage.Reset(every)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +286,28 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
|
||||
}}
|
||||
}
|
||||
|
||||
// sinceOf is the argument since, RFC 3339; absent is the zero time.
|
||||
func sinceOf(a map[string]any) (time.Time, error) {
|
||||
v, _ := a["since"].(string)
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("since is not a time in RFC 3339: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// limitOf is the argument limit: def when absent or not positive, at most most.
|
||||
func limitOf(a map[string]any, def, most int) int {
|
||||
limit := def
|
||||
if v, ok := a["limit"].(float64); ok && v > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
return min(limit, most)
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
@@ -278,11 +320,15 @@ func two(a map[string]any, k1, k2 string) (string, string, error) {
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
consumer := str("the node's name")
|
||||
return []stdio.Tool{
|
||||
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.",
|
||||
return append([]stdio.Tool{
|
||||
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it, its latest usage with its reset times, its room or why it is not known, whether it is where a full licence's consumers move next, when and from where it was adopted, and its open conditions. Never a token.",
|
||||
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }),
|
||||
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given.",
|
||||
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Store.Bindings(ctx) }),
|
||||
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given; with consumer, that consumer's alone.",
|
||||
map[string]any{"consumer": str("a consumer; absent for every one")},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
c, _ := a["consumer"].(string)
|
||||
return m.BindingsOf(ctx, strings.TrimSpace(c))
|
||||
}),
|
||||
verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.",
|
||||
map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
@@ -318,15 +364,18 @@ func tools() []stdio.Tool {
|
||||
}
|
||||
return m.RotateDue(ctx)
|
||||
}),
|
||||
verb("usage", "Usage readings, the latest first, for every licence or one.",
|
||||
map[string]any{"licence": str("a licence; absent for all"), "limit": map[string]any{"type": "number", "description": "how many readings (default 20)"}},
|
||||
verb("usage", "Usage readings, the latest first, for every licence or one: the flattened reading with its three reset times, none before since, at most limit (default 20, at most 2000), and with raw the vendor's answer whole.",
|
||||
map[string]any{"licence": str("a licence; absent for all"), "since": str("a time, RFC 3339: no reading before it"),
|
||||
"limit": map[string]any{"type": "number", "description": "how many readings (default 20, at most 2000)"},
|
||||
"raw": map[string]any{"type": "boolean", "description": "also the vendor's answer whole"}},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
l, _ := a["licence"].(string)
|
||||
limit := 20
|
||||
if v, ok := a["limit"].(float64); ok && v > 0 {
|
||||
limit = int(v)
|
||||
since, err := sinceOf(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Store.Usage(ctx, l, limit)
|
||||
raw, _ := a["raw"].(bool)
|
||||
return m.Store.Usage(ctx, l, since, limitOf(a, 20, 2000), raw)
|
||||
}),
|
||||
verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public-key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.",
|
||||
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"),
|
||||
@@ -356,14 +405,14 @@ func tools() []stdio.Tool {
|
||||
nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) {
|
||||
return map[string]any{"public_key": m.Keys.PublicKey}, nil
|
||||
}),
|
||||
verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.",
|
||||
map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")},
|
||||
verb("current", "For a consumer's agent module: which licence it is bound to, the licence's key, kind and generation, its health (ok, failing or expiring) and when its access token expires; never the token, which the vault hands. Null when it is bound to nothing.",
|
||||
map[string]any{"consumer": consumer},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
c, k, err := two(a, "consumer", "public_key")
|
||||
c, err := text(a, "consumer")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Current(ctx, c, k)
|
||||
return m.Current(ctx, c)
|
||||
}),
|
||||
}
|
||||
}, moduleTools()...)
|
||||
}
|
||||
+398
-196
@@ -23,7 +23,12 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare.
|
||||
@@ -53,17 +58,6 @@ type Holdings struct {
|
||||
Generation int64 `json:"generation"`
|
||||
}
|
||||
|
||||
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
|
||||
// generation.
|
||||
type BindingState struct {
|
||||
Licence string `json:"licence"`
|
||||
Kind string `json:"kind"`
|
||||
Generation int64 `json:"generation"`
|
||||
// Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it.
|
||||
Why string `json:"why,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
|
||||
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
|
||||
type Cause struct {
|
||||
@@ -90,63 +84,52 @@ func CauseOf(ctx context.Context) Cause {
|
||||
return Cause{Why: "a rule of this module's own", By: Itself}
|
||||
}
|
||||
|
||||
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
|
||||
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
|
||||
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
|
||||
_ = m.Store.Audit(ctx, what, detail)
|
||||
// record says one change of what a consumer holds, the same fields in two places (novox/hq issue 625): a line in
|
||||
// the journal and an entry of the audit. The bus hears it as binding.changed (§21); `licence.bound`, `released` and
|
||||
// `rotated` are said no more (B16). Never a token.
|
||||
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error {
|
||||
if err := m.Store.Audit(ctx, what, detail); err != nil {
|
||||
m.Log("%s, but %v", line, err)
|
||||
return err
|
||||
}
|
||||
m.Log("%s", line)
|
||||
_ = m.Emit("licence."+what, detail)
|
||||
return nil
|
||||
}
|
||||
|
||||
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
|
||||
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
|
||||
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
|
||||
if from != "" {
|
||||
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
|
||||
// BindingChanged is the event of a consumer's binding written or deleted, said as `binding.changed.<consumer>`: the consumer is
|
||||
// its further part, the one form of G's seat events (novox/hq design 47 G A-134), so a reader adopts it.
|
||||
const BindingChanged = "binding.changed"
|
||||
|
||||
// changed says that one consumer's key of `bindings` was written or deleted (§21, B7): its licence (empty on a
|
||||
// release) and that licence's key, kind, generation, the licence before, why, by and when. Never a token.
|
||||
func (m *Manager) changed(b Binding, from string) {
|
||||
key := ""
|
||||
if b.Licence != "" {
|
||||
key = KeyFor(b.Licence)
|
||||
}
|
||||
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
|
||||
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
|
||||
"why": c.Why, "by": c.By})
|
||||
_ = m.Emit(BindingChanged+"."+b.Consumer, map[string]any{"consumer": b.Consumer, "licence": b.Licence, "key": key,
|
||||
"kind": b.Kind, "generation": b.Generation, "from": from, "why": b.Why, "by": b.By,
|
||||
"at": m.Now().UTC().Format(time.RFC3339Nano)})
|
||||
}
|
||||
|
||||
// Settings are the manager's own, declared with defaults (design 39 §8).
|
||||
type Settings struct {
|
||||
Cadence time.Duration // rotate a grant once older than this
|
||||
Floor time.Duration // refresh in any case with less than this left
|
||||
FailuresToNotify int
|
||||
Cooldown time.Duration // at most one notification per licence in this window
|
||||
RefreshWarn time.Duration // warn this long before a refresh token itself expires
|
||||
// switchedFields are a switch's fields, the same in the event licence.switched and the audit entry switched (§27).
|
||||
func switchedFields(from, to string, consumers []string, c Cause) map[string]any {
|
||||
return map[string]any{"from": from, "to": to, "consumers": consumers, "by": c.By, "why": c.Why}
|
||||
}
|
||||
|
||||
// Defaults are the settings a fresh mesh runs with.
|
||||
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, RefreshWarn: 72 * time.Hour}
|
||||
|
||||
// SettingsFrom reads the settings file, keeping a default for anything absent or not positive.
|
||||
func SettingsFrom(path string) Settings {
|
||||
s := Defaults
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return s
|
||||
// moved records a consumer bound or switched, after its key is written: in the journal and the audit (a switch with
|
||||
// §27's fields), and as binding.changed.
|
||||
func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) error {
|
||||
what, line := "bound", fmt.Sprintf("bound %s to %s", b.Consumer, b.Licence)
|
||||
detail := map[string]any{"consumer": b.Consumer, "licence": b.Licence, "from": from, "generation": b.Generation,
|
||||
"why": c.Why, "by": c.By}
|
||||
if from != "" && from != b.Licence {
|
||||
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", b.Consumer, from, b.Licence)
|
||||
detail = switchedFields(from, b.Licence, []string{b.Consumer}, c)
|
||||
}
|
||||
var f map[string]float64
|
||||
if json.Unmarshal(raw, &f) != nil {
|
||||
return s
|
||||
}
|
||||
set := func(k string, unit time.Duration, into *time.Duration) {
|
||||
if v := f[k]; v > 0 {
|
||||
*into = time.Duration(v * float64(unit))
|
||||
}
|
||||
}
|
||||
set("cadence_minutes", time.Minute, &s.Cadence)
|
||||
set("floor_minutes", time.Minute, &s.Floor)
|
||||
set("cooldown_hours", time.Hour, &s.Cooldown)
|
||||
if v := f["refresh_warn_days"]; v > 0 {
|
||||
s.RefreshWarn = time.Duration(v * float64(24*time.Hour))
|
||||
}
|
||||
if v := f["failures_to_notify"]; v > 0 {
|
||||
s.FailuresToNotify = int(v)
|
||||
}
|
||||
return s
|
||||
err := m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, b.Generation, c.Why, c.By), detail)
|
||||
m.changed(b, from)
|
||||
return err
|
||||
}
|
||||
|
||||
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
|
||||
@@ -160,20 +143,31 @@ type GrantAnswer struct {
|
||||
type Manager struct {
|
||||
Store Store
|
||||
Vendor Vendor
|
||||
Crypt *Crypt
|
||||
// Grants is the vault, which holds every licence's grant and access token (§15, §16).
|
||||
Grants Grants
|
||||
Keys KeyPair
|
||||
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
|
||||
// is the node not answering; a nil Sealed is no login waiting.
|
||||
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
|
||||
// PutBinding and DeleteBinding are this module's `bindings` state.
|
||||
PutBinding func(ctx context.Context, consumer string, b BindingState) error
|
||||
DeleteBinding func(ctx context.Context, consumer string) error
|
||||
Emit func(event string, body map[string]any) error
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
// Reported is the binding generation a node last reported applying (B11), 0 when none is known.
|
||||
Reported func(node string) int64
|
||||
Emit func(event string, body map[string]any) error
|
||||
// Tell tells the operator one notice that needs no answer, through the seat operator-channel's tell (§28).
|
||||
Tell func(ctx context.Context, headline string, lines []string) error
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
// Holder names this process in a lease, so a second run is told apart.
|
||||
Holder string
|
||||
Settings Settings
|
||||
Holder string
|
||||
// Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile
|
||||
// keeps them.
|
||||
Settings Settings
|
||||
SettingsFile string
|
||||
settingsMu sync.RWMutex
|
||||
// Conditions is the state `conditions` (design 39 bus-and-switch §9), and Say says one condition on the operator
|
||||
// channel; either nil says none.
|
||||
Conditions State
|
||||
Say func(c oc.Condition) error
|
||||
conditionsMu sync.Mutex
|
||||
}
|
||||
|
||||
const leaseFor = 2 * time.Minute
|
||||
@@ -239,17 +233,9 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str
|
||||
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
|
||||
// exchanged. Answers what was adopted.
|
||||
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
|
||||
// **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss
|
||||
// counts from one again, while every node still holds the number it last applied. The nodes no longer
|
||||
// wait for a higher number, but a binding numbered exactly as the one a node applied would still be
|
||||
// skipped. So the count is moved past every number reported, before anything here binds.
|
||||
var highest int64
|
||||
for _, r := range reports {
|
||||
highest = max(highest, r.Generation)
|
||||
}
|
||||
if err := m.Store.GenerationsAbove(ctx, highest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// **Never a generation a node has already passed** (novox/hq issue 243, B11): each consumer's count is its own,
|
||||
// and moves past the number its node reported applying.
|
||||
floor := m.floorIn(reports)
|
||||
byAccount, err := m.CandidatesIn(ctx, reports)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -262,19 +248,19 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
|
||||
// A node reporting an account the manager already holds, and bound to nothing, is bound to it
|
||||
// (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own
|
||||
// login is older than the one adopted is never a candidate, and would otherwise never be bound.
|
||||
if err := m.bindReporters(ctx, reports); err != nil {
|
||||
if err := m.bindReporters(ctx, reports, floor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var adopted []string
|
||||
for _, account := range accounts {
|
||||
list := byAccount[account]
|
||||
key := "account:" + account
|
||||
key := "account." + account
|
||||
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
for i, c := range list {
|
||||
name, err := m.adoptOne(ctx, c, reports)
|
||||
name, err := m.adoptOne(ctx, c, reports, floor)
|
||||
if err != nil {
|
||||
m.Log("adopting %s's login failed: %v", c.Node, err)
|
||||
continue
|
||||
@@ -293,7 +279,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
|
||||
}
|
||||
|
||||
// bindReporters binds each reporting node that is bound to nothing to the licence its account already has.
|
||||
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
|
||||
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor func(string) int64) error {
|
||||
for _, rep := range reports {
|
||||
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
|
||||
continue
|
||||
@@ -311,21 +297,19 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
b, err := m.Store.Bind(ctx, rep.Node, l.Name)
|
||||
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
|
||||
b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
|
||||
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
|
||||
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation,
|
||||
Why: c.Why, By: c.By}); err != nil {
|
||||
if err := m.moved(ctx, b, "", c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) {
|
||||
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, floor func(string) int64) (string, error) {
|
||||
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
|
||||
if err != nil {
|
||||
// Not answering is not an answer: asked again on the next pass.
|
||||
@@ -348,12 +332,34 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// A new licence whose key another licence already has is refused before its grant is exchanged
|
||||
// (design 39 bus-and-switch §10).
|
||||
if held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID); err != nil {
|
||||
return "", err
|
||||
} else if held == nil {
|
||||
taken, err := m.keyTaken(ctx, NameFor(c.Identity))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if taken != "" {
|
||||
why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why)
|
||||
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why})
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the
|
||||
// only holder of a live refresh token for the account.
|
||||
r := m.Vendor.Refresh(ctx, offered)
|
||||
if !r.OK {
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason))
|
||||
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason})
|
||||
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
||||
"reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)})
|
||||
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
|
||||
@@ -363,7 +369,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
if r.Account != "" && r.Account != c.Identity.AccountUUID {
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
|
||||
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
|
||||
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account})
|
||||
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
||||
"reason": "the account the node reported is not the one the vendor answered for"})
|
||||
return "", nil
|
||||
@@ -375,9 +383,10 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
}
|
||||
now := m.Now().UnixMilli()
|
||||
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
|
||||
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
|
||||
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID, AdoptedFrom: c.Node}
|
||||
if held != nil {
|
||||
l.Name, l.NotifiedAt = held.Name, held.NotifiedAt
|
||||
// A newer login keeps what the key holds beside its grant: its latest reading (§2, §24).
|
||||
l.Name, l.NotifiedAt, l.Generation, l.Usage = held.Name, held.NotifiedAt, held.Generation, held.Usage
|
||||
if l.Email == "" {
|
||||
l.Email = held.Email
|
||||
}
|
||||
@@ -385,7 +394,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
l.OrganizationUUID = held.OrganizationUUID
|
||||
}
|
||||
}
|
||||
m.keep(&l, r.Grant)
|
||||
if err := m.keep(ctx, &l, r.Grant); err != nil {
|
||||
return "", err
|
||||
}
|
||||
l.AdoptedAt = max(now, c.ChangedAt)
|
||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||
return "", err
|
||||
@@ -395,18 +406,25 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
why = "a newer login"
|
||||
}
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
|
||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
|
||||
"vendorNamedAccount": r.Account != ""})
|
||||
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
|
||||
"vendorNamedAccount": r.Account != ""}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
|
||||
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
|
||||
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
|
||||
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
|
||||
ctx = WithCause(ctx, cause)
|
||||
// The consumers already bound to it take the new grant first, so each consumer this adoption moves is said once
|
||||
// (§21).
|
||||
if err := m.publishAdvance(ctx, l, floor); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
|
||||
return "", err
|
||||
} else if b == nil || b.Licence != l.Name {
|
||||
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
|
||||
moved, err := m.Store.Bind(ctx, c.Node, l.Name, Move{Kind: l.Kind, Why: cause.Why, By: cause.By, Floor: floor})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -414,7 +432,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
if b != nil {
|
||||
from = b.Licence
|
||||
}
|
||||
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
|
||||
if err := m.moved(ctx, moved, from, cause); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
for _, rep := range reports {
|
||||
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
||||
@@ -423,23 +443,25 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
|
||||
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
||||
continue
|
||||
}
|
||||
if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
|
||||
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
|
||||
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
|
||||
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
|
||||
if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil {
|
||||
if err := m.moved(ctx, b, "", bc); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := m.publishAdvance(ctx, l); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
|
||||
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
|
||||
return l.Name, nil
|
||||
}
|
||||
|
||||
// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh.
|
||||
func (m *Manager) keep(l *Licence, g FullGrant) {
|
||||
raw, _ := json.Marshal(g)
|
||||
l.Sealed = m.Crypt.Seal(string(raw))
|
||||
// keep hands a grant to the vault at the licence's next generation, the grant first and then its access (design 39
|
||||
// bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns.
|
||||
func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error {
|
||||
grant, _ := json.Marshal(g)
|
||||
if err := m.handOver(ctx, l, string(grant), accessOf(g)); err != nil {
|
||||
return err
|
||||
}
|
||||
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
|
||||
l.AccessExpiresAt = g.ExpiresAt
|
||||
if g.RefreshTokenExpiresAt != nil {
|
||||
@@ -447,30 +469,73 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
|
||||
}
|
||||
l.Failures = 0
|
||||
l.RotatedAt = m.Now().UnixMilli()
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
|
||||
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
|
||||
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
|
||||
bindings, err := m.Store.Advance(ctx, l.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := CauseOf(ctx)
|
||||
for _, b := range bindings {
|
||||
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
|
||||
Why: c.Why, By: c.By}); err != nil {
|
||||
return err
|
||||
// accessOf is a subscription's `access` secret: its access token and expiry, never its refresh token (§15).
|
||||
func accessOf(g FullGrant) string {
|
||||
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
|
||||
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
|
||||
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
|
||||
return string(access)
|
||||
}
|
||||
|
||||
// handOver puts a licence's two secrets in the vault at its next generation, the grant first (§15).
|
||||
func (m *Manager) handOver(ctx context.Context, l *Licence, grant, access string) error {
|
||||
generation := l.Generation + 1
|
||||
for _, part := range []struct{ name, value string }{{PartGrant, grant}, {PartAccess, access}} {
|
||||
if err := m.Grants.Put(ctx, KeyFor(l.Name), part.name, generation, part.value); err != nil {
|
||||
return fmt.Errorf("the vault did not take the %s of %s: %w", part.name, l.Name, err)
|
||||
}
|
||||
}
|
||||
l.Generation = generation
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantOf reads a subscription's grant from the vault for one act (§16); the caller drops it when the act ends.
|
||||
func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) {
|
||||
var g FullGrant
|
||||
plain, err := m.Grants.Get(ctx, KeyFor(l.Name), PartGrant)
|
||||
if err != nil {
|
||||
return g, fmt.Errorf("the grant of %s cannot be read from the vault: %w", l.Name, err)
|
||||
}
|
||||
return g, json.Unmarshal([]byte(plain), &g)
|
||||
}
|
||||
|
||||
// publishAdvance gives every consumer of a licence a new generation, with the cause ctx carries, so the node says why
|
||||
// it fetched again (novox/hq issue 625).
|
||||
func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error {
|
||||
c := CauseOf(ctx)
|
||||
advanced, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
|
||||
for _, b := range advanced {
|
||||
m.changed(b, b.Licence)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// floorIn is the floor of each consumer's generation (B11): the higher of what its node reported in reports and what
|
||||
// Reported says it last applied. A worker's node is the part of its name before the first dot (ADR 0183 §19).
|
||||
func (m *Manager) floorIn(reports []Holdings) func(string) int64 {
|
||||
return func(consumer string) int64 {
|
||||
node, _, _ := strings.Cut(consumer, ".")
|
||||
var f int64
|
||||
for _, r := range reports {
|
||||
if r.Node == node {
|
||||
f = max(f, r.Generation)
|
||||
}
|
||||
}
|
||||
if m.Reported != nil {
|
||||
f = max(f, m.Reported(node))
|
||||
}
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
// ---- keeping grants alive ----------------------------------------------------------------------------
|
||||
|
||||
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
|
||||
func Due(l Licence, now time.Time, s Settings) bool {
|
||||
if l.Kind != "subscription" || l.Sealed == "" {
|
||||
if l.Kind != "subscription" || l.Generation == 0 {
|
||||
return false
|
||||
}
|
||||
ms := now.UnixMilli()
|
||||
@@ -484,7 +549,7 @@ func Due(l Licence, now time.Time, s Settings) bool {
|
||||
// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes
|
||||
// whatever the age — the seat's `refresh` verb.
|
||||
func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) {
|
||||
key := "licence:" + name
|
||||
key := "licence." + KeyFor(name)
|
||||
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -500,37 +565,38 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
||||
if l == nil {
|
||||
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
|
||||
}
|
||||
if l.Kind != "subscription" || l.Sealed == "" {
|
||||
if l.Kind != "subscription" || l.Generation == 0 {
|
||||
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
|
||||
}
|
||||
now := m.Now()
|
||||
if !force && !Due(*l, now, m.Settings) {
|
||||
if !force && !Due(*l, now, m.settings()) {
|
||||
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
|
||||
}
|
||||
plain, err := m.Crypt.Open(l.Sealed)
|
||||
g, err := m.grantOf(ctx, *l)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var g FullGrant
|
||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := m.Vendor.Refresh(ctx, g)
|
||||
g = FullGrant{}
|
||||
if !r.OK {
|
||||
l.Failures++
|
||||
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
|
||||
_ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures})
|
||||
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
|
||||
if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.settings().FailuresToNotify)
|
||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
|
||||
}
|
||||
m.keep(l, r.Grant)
|
||||
if err := m.keep(ctx, l, r.Grant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if l.RefreshExpiresAt != 0 {
|
||||
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
|
||||
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
|
||||
left < m.Settings.RefreshWarn)
|
||||
left < m.settings().RefreshWarn)
|
||||
}
|
||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||
return nil, err
|
||||
@@ -545,10 +611,12 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
||||
}
|
||||
}
|
||||
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
|
||||
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
|
||||
if err := m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
|
||||
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
|
||||
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
|
||||
if err := m.publishAdvance(ctx, *l); err != nil {
|
||||
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
|
||||
@@ -560,7 +628,7 @@ func (m *Manager) notify(l *Licence, why string, when bool) {
|
||||
return
|
||||
}
|
||||
now := m.Now().UnixMilli()
|
||||
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() {
|
||||
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.settings().Cooldown.Milliseconds() {
|
||||
return
|
||||
}
|
||||
l.NotifiedAt = now
|
||||
@@ -575,7 +643,7 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, l := range all {
|
||||
if Due(l, m.Now(), m.Settings) {
|
||||
if Due(l, m.Now(), m.settings()) {
|
||||
r, err := m.Rotate(ctx, l.Name, false)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -586,79 +654,165 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers.
|
||||
// ReadUsage reads every subscription's usage, on the manager's cadence (§24): each reading written, the rule applied
|
||||
// after it (§26), and the conditions said (§9).
|
||||
func (m *Manager) ReadUsage(ctx context.Context) error {
|
||||
_, err := m.ReadUsageOf(ctx, "", false)
|
||||
return err
|
||||
}
|
||||
|
||||
// Read is one reading as licences_read_usage answers it (§38): the licence, the reading or why it failed, and what §26
|
||||
// did or, with dry, would do.
|
||||
type Read struct {
|
||||
Licence string `json:"licence"`
|
||||
Reading *UsageReading `json:"reading,omitempty"`
|
||||
Error string `json:"readError,omitempty"`
|
||||
Switches []Switch `json:"switches"`
|
||||
}
|
||||
|
||||
// ReadUsageOf reads the usage of one subscription, or every one when licence is empty, as §24 does, and applies §26
|
||||
// after each reading unless dry; dry answers what §26 would do (§32, §38).
|
||||
func (m *Manager) ReadUsageOf(ctx context.Context, licence string, dry bool) ([]Read, error) {
|
||||
if err := m.ReadSettings(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, err := m.Store.Licences(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
out := []Read{}
|
||||
for _, l := range all {
|
||||
if l.Kind != "subscription" || l.Sealed == "" {
|
||||
if l.Kind != "subscription" || l.Generation == 0 || (licence != "" && l.Name != licence) {
|
||||
continue
|
||||
}
|
||||
plain, err := m.Crypt.Open(l.Sealed)
|
||||
// The reading takes the licence's grant under its lease, so it never reads a grant a refresh is replacing (§4,
|
||||
// §16); a grant the vault does not answer is a failed reading like a vendor's refusal (§31).
|
||||
key := "licence." + KeyFor(l.Name)
|
||||
leased, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
|
||||
if err != nil {
|
||||
return err
|
||||
return out, err
|
||||
}
|
||||
var g FullGrant
|
||||
_ = json.Unmarshal([]byte(plain), &g)
|
||||
raw, err := m.Vendor.Usage(ctx, g.AccessToken)
|
||||
if err != nil || raw == nil {
|
||||
if !leased {
|
||||
out = append(out, Read{Licence: l.Name, Error: "another run holds its lease", Switches: []Switch{}})
|
||||
continue
|
||||
}
|
||||
unlease := func() { _ = m.Store.Unlease(ctx, key, m.Holder) }
|
||||
if fresh, err := m.Store.Licence(ctx, l.Name); err == nil && fresh != nil {
|
||||
l = *fresh
|
||||
}
|
||||
var raw map[string]any
|
||||
g, err := m.grantOf(ctx, l)
|
||||
if err == nil {
|
||||
raw, err = m.Vendor.Usage(ctx, g.AccessToken)
|
||||
}
|
||||
g = FullGrant{}
|
||||
if err == nil && raw == nil {
|
||||
err = errors.New("the usage endpoint answered nothing")
|
||||
}
|
||||
if err != nil {
|
||||
// A failed reading keeps the last percentages and writes the vendor's status and reason (§24).
|
||||
kept := UsageReading{}
|
||||
if l.Usage != nil {
|
||||
kept = *l.Usage
|
||||
}
|
||||
kept.ReadError = err.Error()
|
||||
l.Usage = &kept
|
||||
err := m.Store.SaveLicence(ctx, l)
|
||||
unlease()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
m.Log("reading the usage of %s failed: %v", l.Name, err)
|
||||
out = append(out, Read{Licence: l.Name, Error: kept.ReadError, Switches: []Switch{}})
|
||||
continue
|
||||
}
|
||||
reading := FlattenUsage(raw)
|
||||
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
|
||||
return err
|
||||
unlease()
|
||||
return out, err
|
||||
}
|
||||
// The latest reading beside its licence, so licences answers from one key (§2).
|
||||
latest := reading
|
||||
latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano)
|
||||
l.Usage = &latest
|
||||
err = m.Store.SaveLicence(ctx, l)
|
||||
unlease()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
|
||||
"weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct})
|
||||
"sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct,
|
||||
"weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct,
|
||||
"sonnetResetsAt": reading.SonnetResetsAt})
|
||||
// On each reading, a licence at its threshold moves every consumer bound to it (§26).
|
||||
acts, err := m.ApplyRule(ctx, l.Name, false, dry)
|
||||
if acts == nil {
|
||||
acts = []Switch{}
|
||||
}
|
||||
out = append(out, Read{Licence: l.Name, Reading: &latest, Switches: acts})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// What the readings and the settings now hold is said, each condition only when what it says changed (§9).
|
||||
return out, m.SayConditions(ctx, false)
|
||||
}
|
||||
|
||||
// ---- the seat's verbs --------------------------------------------------------------------------------
|
||||
|
||||
// Current is a consumer's token sealed to the key it sent (ADR 0206 §6): for a subscription the access
|
||||
// token and its expiries only — never the refresh token, which no node holds. Nil when it is bound to
|
||||
// Current is which licence a consumer is bound to and its health, never the token (design 39 bus-and-switch §18, B7,
|
||||
// B19): the licence, its key (the name of its secrets in the vault), kind, generation, health and accessExpiresAt, and
|
||||
// for a subscription the account's identity the agent module points its account file at. Nil when it is bound to
|
||||
// nothing.
|
||||
func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[string]any, error) {
|
||||
if !strings.Contains(publicKey, "PUBLIC KEY") {
|
||||
return nil, errors.New("current seals to the consumer's public key, and none was given")
|
||||
}
|
||||
func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, error) {
|
||||
b, err := m.Store.Binding(ctx, consumer)
|
||||
if err != nil || b == nil {
|
||||
return nil, err
|
||||
}
|
||||
l, err := m.Store.Licence(ctx, b.Licence)
|
||||
if err != nil || l == nil || l.Sealed == "" {
|
||||
if err != nil || l == nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := m.Crypt.Open(l.Sealed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation,
|
||||
"health": Health(*l, m.Now(), m.settings())}
|
||||
if l.AccessExpiresAt != 0 {
|
||||
out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)
|
||||
}
|
||||
handed := plain
|
||||
if l.Kind == "subscription" {
|
||||
var g FullGrant
|
||||
if err := json.Unmarshal([]byte(plain), &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
|
||||
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
|
||||
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
|
||||
handed = string(access)
|
||||
}
|
||||
box, err := Seal(handed, publicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"licence": l.Name, "kind": l.Kind, "generation": b.Generation, "sealed": box}
|
||||
if l.AccountUUID != "" {
|
||||
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Health is a licence's health (B19): `failing` once its refresh failed failures_to_notify times in a row, `expiring`
|
||||
// when its refresh token expires within refresh_warn_days, else `ok`.
|
||||
func Health(l Licence, now time.Time, s Settings) string {
|
||||
switch {
|
||||
case l.Failures >= s.FailuresToNotify:
|
||||
return "failing"
|
||||
case l.RefreshExpiresAt != 0 && l.RefreshExpiresAt-now.UnixMilli() < s.RefreshWarn.Milliseconds():
|
||||
return "expiring"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// BindingsOf is every binding, or the one of consumer when one is named (B10): a list either way, unpaged, each with
|
||||
// its licence's key beside the §3 fields (B25).
|
||||
func (m *Manager) BindingsOf(ctx context.Context, consumer string) ([]Binding, error) {
|
||||
all, err := m.Store.Bindings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Binding{}
|
||||
for _, b := range all {
|
||||
if consumer == "" || b.Consumer == consumer {
|
||||
b.Key = KeyFor(b.Licence)
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
|
||||
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
|
||||
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
|
||||
@@ -673,7 +827,8 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := m.Store.Bind(ctx, consumer, licence)
|
||||
c := CauseOf(ctx)
|
||||
b, err := m.Store.Bind(ctx, consumer, licence, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: m.floorIn(nil)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -681,12 +836,12 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
|
||||
if before != nil {
|
||||
from = before.Licence
|
||||
}
|
||||
c := CauseOf(ctx)
|
||||
m.moved(ctx, consumer, from, licence, b.Generation, c)
|
||||
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation,
|
||||
Why: c.Why, By: c.By}); err != nil {
|
||||
if err := m.moved(ctx, b, from, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if from != "" && from != licence {
|
||||
_ = m.Emit("licence.switched", switchedFields(from, licence, []string{consumer}, c)) // one per act (§27, §36)
|
||||
}
|
||||
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
||||
}
|
||||
|
||||
@@ -699,12 +854,12 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
|
||||
if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok {
|
||||
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
|
||||
}
|
||||
if err := m.DeleteBinding(ctx, consumer); err != nil {
|
||||
c := CauseOf(ctx)
|
||||
if err := m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
|
||||
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := CauseOf(ctx)
|
||||
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
|
||||
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
|
||||
m.changed(Binding{Consumer: consumer, Generation: was.Generation, Why: c.Why, By: c.By}, was.Licence)
|
||||
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
||||
}
|
||||
|
||||
@@ -751,22 +906,44 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
|
||||
if held != nil && held.Kind != "api-key" {
|
||||
return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name)
|
||||
}
|
||||
l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()}
|
||||
taken, err := m.keyTaken(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken != "" {
|
||||
why := "its key " + KeyFor(name) + " is the key of the licence " + taken
|
||||
if err := m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why})
|
||||
return nil, fmt.Errorf("%s is refused: %s", name, why)
|
||||
}
|
||||
l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli(), AdoptedFrom: from}
|
||||
if held != nil {
|
||||
l.Generation, l.Usage = held.Generation, held.Usage
|
||||
}
|
||||
if err := m.handOver(ctx, &l, key, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.Store.SaveLicence(ctx, l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := CauseOf(ctx)
|
||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
|
||||
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
|
||||
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
|
||||
if err := m.publishAdvance(ctx, l); err != nil {
|
||||
if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil})
|
||||
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
|
||||
}
|
||||
|
||||
// Licences is each licence as a person reads it: health and who is bound, never a token.
|
||||
// Licences is each licence as a person reads it (design 39 bus-and-switch §34, §47, B19, B40): today's fields, its
|
||||
// latest usage with its reset times, its room or why it is not known, whether it is the target a full licence's
|
||||
// consumers move to next, when and from where it was adopted, and the conditions open about it. Never a token.
|
||||
func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
|
||||
all, err := m.Store.Licences(ctx)
|
||||
if err != nil {
|
||||
@@ -776,27 +953,52 @@ func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
open, err := KeptConditions(m.Conditions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := m.settings()
|
||||
stamp := func(ms int64) any {
|
||||
if ms == 0 {
|
||||
return nil
|
||||
}
|
||||
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
|
||||
}
|
||||
targets := map[string]bool{}
|
||||
for _, l := range all {
|
||||
if len(consumersOf(l.Name, bindings)) > 0 {
|
||||
if to, _ := m.targetFor(l, all, s); to != "" {
|
||||
targets[to] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, l := range all {
|
||||
bound := []string{}
|
||||
for _, b := range bindings {
|
||||
if b.Licence == l.Name {
|
||||
bound = append(bound, b.Consumer)
|
||||
}
|
||||
bound := consumersOf(l.Name, bindings)
|
||||
if bound == nil {
|
||||
bound = []string{}
|
||||
}
|
||||
account := l.Email
|
||||
if account == "" {
|
||||
account = l.AccountUUID
|
||||
}
|
||||
var room any = m.roomWords(l, s)
|
||||
if r, known := Room(l, m.Now(), s); known {
|
||||
room = r
|
||||
}
|
||||
conditions := []map[string]any{}
|
||||
prefix := "licence." + KeyFor(l.Name) + "."
|
||||
for _, c := range open {
|
||||
if strings.HasPrefix(c.Body.Key, prefix) {
|
||||
conditions = append(conditions, map[string]any{"key": c.Body.Key, "severity": c.Body.Severity,
|
||||
"headline": c.Body.Headline, "since": c.Since})
|
||||
}
|
||||
}
|
||||
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
|
||||
"accessExpiresAt": stamp(l.AccessExpiresAt), "refreshExpiresAt": stamp(l.RefreshExpiresAt),
|
||||
"rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, "bound": bound})
|
||||
"organisation": l.OrganizationUUID, "accessExpiresAt": stamp(l.AccessExpiresAt),
|
||||
"refreshExpiresAt": stamp(l.RefreshExpiresAt), "rotatedAt": stamp(l.RotatedAt), "failures": l.Failures,
|
||||
"bound": bound, "usage": l.Usage, "room": room, "target": targets[l.Name], "adoptedAt": stamp(l.AdoptedAt),
|
||||
"adoptedFrom": l.AdoptedFrom, "conditions": conditions})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+249
-88
@@ -10,6 +10,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
var t0 = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
|
||||
@@ -22,6 +24,9 @@ type stubVendor struct {
|
||||
issued int
|
||||
account string
|
||||
now func() time.Time
|
||||
// usage is what the usage endpoint answers, and usageErr its failure; nil answers a session window alone.
|
||||
usage map[string]any
|
||||
usageErr error
|
||||
}
|
||||
|
||||
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
||||
@@ -44,31 +49,89 @@ func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
|
||||
}
|
||||
|
||||
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
|
||||
if v.usageErr != nil {
|
||||
return nil, v.usageErr
|
||||
}
|
||||
if v.usage != nil {
|
||||
return v.usage, nil
|
||||
}
|
||||
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
|
||||
}
|
||||
|
||||
// memGrants is the vault as the manager sees it: every licence's two secrets by name, the newest generation kept.
|
||||
type memGrants struct {
|
||||
mu sync.Mutex
|
||||
held map[string]heldGrant
|
||||
puts []string
|
||||
// refuseAccess is the vault refusing a licence's access token to the manager's machine, as it does for a licence no
|
||||
// binding there names (§17).
|
||||
refuseAccess bool
|
||||
}
|
||||
|
||||
type heldGrant struct {
|
||||
generation int64
|
||||
value string
|
||||
}
|
||||
|
||||
func (g *memGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
name := SecretName(key, part)
|
||||
if h, ok := g.held[name]; ok && h.generation == generation && h.value == value {
|
||||
return nil // the vault keeps what it holds, unchanged
|
||||
} else if ok && h.generation >= generation {
|
||||
return fmt.Errorf("%s is held at generation %d, and generation %d is not newer", name, h.generation, generation)
|
||||
}
|
||||
g.held[name] = heldGrant{generation, value}
|
||||
g.puts = append(g.puts, fmt.Sprintf("%s@%d", name, generation))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *memGrants) Get(_ context.Context, key, part string) (string, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
h, ok := g.held[SecretName(key, part)]
|
||||
if g.refuseAccess && part == PartAccess {
|
||||
return "", fmt.Errorf("no consumer on this machine is bound to the licence %s", key)
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("the vault holds no secret named %s", SecretName(key, part))
|
||||
}
|
||||
return h.value, nil
|
||||
}
|
||||
|
||||
type miniMesh struct {
|
||||
m *Manager
|
||||
store *MemoryStore
|
||||
vendor *stubVendor
|
||||
logins map[string]FullGrant // node → what its credentials file holds
|
||||
state map[string]BindingState
|
||||
events []string
|
||||
now time.Time
|
||||
keys KeyPair
|
||||
askDown bool
|
||||
grants *memGrants
|
||||
m *Manager
|
||||
store *BusStore
|
||||
usage *memLog
|
||||
audit *memLog
|
||||
licences *memState
|
||||
bindings *memState
|
||||
leases *memState
|
||||
offered *memState
|
||||
vendor *stubVendor
|
||||
logins map[string]FullGrant // node → what its credentials file holds
|
||||
events []string
|
||||
now time.Time
|
||||
keys KeyPair
|
||||
askDown bool
|
||||
}
|
||||
|
||||
func newMesh(t *testing.T) *miniMesh {
|
||||
t.Helper()
|
||||
mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0}
|
||||
mm := &miniMesh{logins: map[string]FullGrant{}, now: t0,
|
||||
grants: &memGrants{held: map[string]heldGrant{}}}
|
||||
now := func() time.Time { return mm.now }
|
||||
mm.store = NewMemoryStore(now)
|
||||
mm.usage, mm.audit = newMemLog(now), newMemLog(now)
|
||||
mm.licences, mm.bindings = newMemState(now), newMemState(now)
|
||||
mm.leases, mm.offered = newMemState(now), newMemState(now)
|
||||
mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases,
|
||||
OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now}
|
||||
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
||||
crypt, _ := NewCrypt("a key the vault made")
|
||||
mm.keys, _ = GenerateKeyPair()
|
||||
mm.m = &Manager{
|
||||
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys,
|
||||
Store: mm.store, Vendor: mm.vendor, Grants: mm.grants, Keys: mm.keys,
|
||||
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
|
||||
if mm.askDown {
|
||||
return GrantAnswer{}, fmt.Errorf("503 no responders")
|
||||
@@ -81,8 +144,6 @@ func newMesh(t *testing.T) *miniMesh {
|
||||
box, err := Seal(string(raw), publicKey)
|
||||
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
|
||||
},
|
||||
PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil },
|
||||
DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil },
|
||||
Emit: func(event string, body map[string]any) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
mm.events = append(mm.events, event+" "+string(raw))
|
||||
@@ -93,6 +154,15 @@ func newMesh(t *testing.T) *miniMesh {
|
||||
return mm
|
||||
}
|
||||
|
||||
// bound is a consumer's value in the state bindings, as its readers decode it.
|
||||
func (mm *miniMesh) bound(consumer string) BindingState {
|
||||
var b BindingState
|
||||
if e, _ := mm.bindings.Get(consumer); e != nil {
|
||||
_ = json.Unmarshal(e.Value, &b)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings {
|
||||
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
|
||||
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
|
||||
@@ -129,15 +199,14 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
|
||||
}
|
||||
// A first binding follows the login: both nodes reported this account and were bound to nothing.
|
||||
bs, _ := mm.store.Bindings(ctx)
|
||||
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
||||
t.Fatalf("bindings %v, state %v", bs, mm.state)
|
||||
if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
|
||||
t.Fatalf("bindings %v, state %v", bs, mm.bindings.values)
|
||||
}
|
||||
// The adoption is said, and so is each binding it made (novox/hq issue 625).
|
||||
// The adoption is said, and so is each binding it made, once (novox/hq issue 625; design 39 bus-and-switch §21).
|
||||
events := strings.Join(mm.events, "|")
|
||||
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
|
||||
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
|
||||
if !strings.Contains(events, want) {
|
||||
t.Fatalf("events lack %s: %v", want, mm.events)
|
||||
for _, want := range []string{"licence.adopted ", `"consumer":"laptop"`, `"consumer":"server"`} {
|
||||
if strings.Count(events, want) != 1 {
|
||||
t.Fatalf("events say %s other than once: %v", want, mm.events)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +238,12 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
|
||||
before, _ := mm.store.Licence(ctx, licence1)
|
||||
// §2: a licence says where it was adopted from, and a newer login keeps the latest reading the key holds.
|
||||
pct := 42.0
|
||||
before.Usage = &UsageReading{SessionPct: &pct, ReadAt: t0.Format(time.RFC3339Nano)}
|
||||
if before.AdoptedFrom != "laptop" || mm.store.SaveLicence(ctx, *before) != nil {
|
||||
t.Fatalf("the adopted licence is %+v", before)
|
||||
}
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
|
||||
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
|
||||
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
|
||||
@@ -179,6 +254,9 @@ func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
|
||||
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
|
||||
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
|
||||
}
|
||||
if after.AdoptedFrom != "desktop" || after.Usage == nil || after.Usage.SessionPct == nil || *after.Usage.SessionPct != 42 {
|
||||
t.Fatalf("after a newer login the licence is %+v", after)
|
||||
}
|
||||
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
|
||||
t.Fatalf("one account became %d licences", len(ls))
|
||||
}
|
||||
@@ -201,11 +279,12 @@ func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
mm.now = t0.Add(5 * time.Hour)
|
||||
second := *mm.m
|
||||
second.Holder = "another run"
|
||||
second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Grants: mm.m.Grants, Keys: mm.m.Keys,
|
||||
AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run",
|
||||
Settings: mm.m.Settings}
|
||||
var wg sync.WaitGroup
|
||||
results := make([]map[string]any, 2)
|
||||
for i, m := range []*Manager{mm.m, &second} {
|
||||
for i, m := range []*Manager{mm.m, second} {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
|
||||
}
|
||||
@@ -228,9 +307,9 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
g0 := mm.state["laptop"].Generation
|
||||
g0 := mm.bound("laptop").Generation
|
||||
_, _ = mm.m.Rotate(ctx, licence1, true)
|
||||
g1 := mm.state["laptop"].Generation
|
||||
g1 := mm.bound("laptop").Generation
|
||||
if g1 <= g0 {
|
||||
t.Fatalf("a rotation went from %d to %d", g0, g1)
|
||||
}
|
||||
@@ -242,43 +321,54 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
|
||||
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
|
||||
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
|
||||
if mm.bound("laptop").Licence != "api" || mm.bound("laptop").Generation <= g1 {
|
||||
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.bound("laptop"))
|
||||
}
|
||||
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := mm.state["laptop"]; ok {
|
||||
if _, ok := mm.bindings.values["laptop"]; ok {
|
||||
t.Fatal("a released consumer still has a binding in the state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) {
|
||||
// design 39 bus-and-switch §18, B7, B19: no answer of current holds a token; it names the licence's key and health.
|
||||
func TestCurrentHoldsNoToken(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
node, _ := GenerateKeyPair()
|
||||
answer, err := mm.m.Current(ctx, "laptop", node.PublicKey)
|
||||
if err != nil || answer["kind"] != "subscription" {
|
||||
answer, err := mm.m.Current(ctx, "laptop")
|
||||
if err != nil || answer["kind"] != "subscription" || answer["key"] != KeyFor(licence1) || answer["health"] != "ok" ||
|
||||
answer["accessExpiresAt"] == nil || answer["identity"] == nil {
|
||||
t.Fatalf("%v %v", answer, err)
|
||||
}
|
||||
box := answer["sealed"].(SealedBox)
|
||||
plain, err := Open(box, node.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
raw, _ := json.Marshal(answer)
|
||||
for _, token := range []string{"rt-", "at-", "sealed"} {
|
||||
if strings.Contains(string(raw), token) {
|
||||
t.Fatalf("current answered %s: %s", token, raw)
|
||||
}
|
||||
}
|
||||
var handed map[string]any
|
||||
_ = json.Unmarshal([]byte(plain), &handed)
|
||||
if !strings.HasPrefix(handed["accessToken"].(string), "at-") || handed["refreshToken"] != nil {
|
||||
t.Fatalf("handed %v", handed)
|
||||
l, _ := mm.store.Licence(ctx, licence1)
|
||||
for failures, health := range map[int]string{0: "ok", 3: "failing"} {
|
||||
l.Failures = failures
|
||||
if got := Health(*l, mm.now, mm.m.Settings); got != health {
|
||||
t.Errorf("%d failures: %s, want %s", failures, got, health)
|
||||
}
|
||||
}
|
||||
other, _ := GenerateKeyPair()
|
||||
if _, err := Open(box, other.PrivateKey); err == nil {
|
||||
t.Fatal("the hand-over opened with another key")
|
||||
l.Failures, l.RefreshExpiresAt = 0, mm.now.Add(48*time.Hour).UnixMilli()
|
||||
if got := Health(*l, mm.now, mm.m.Settings); got != "expiring" {
|
||||
t.Errorf("a refresh token expiring in two days: %s", got)
|
||||
}
|
||||
if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil {
|
||||
if a, _ := mm.m.Current(ctx, "nobody"); a != nil {
|
||||
t.Fatalf("a consumer bound to nothing was answered %v", a)
|
||||
}
|
||||
if bs, _ := mm.m.BindingsOf(ctx, "laptop"); len(bs) != 1 || bs[0].Consumer != "laptop" {
|
||||
t.Fatalf("bindings of laptop: %v", bs)
|
||||
}
|
||||
// design 39 bus-and-switch B25: the verb bindings answers each binding's licence key beside the §3 fields.
|
||||
if bs, _ := mm.m.BindingsOf(ctx, ""); len(bs) == 0 || bs[0].Key != KeyFor(bs[0].Licence) {
|
||||
t.Fatalf("bindings answered %+v", bs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
||||
@@ -290,15 +380,15 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
|
||||
_ = mm.m.ReadUsage(ctx)
|
||||
ls, _ := mm.m.Licences(ctx)
|
||||
bs, _ := mm.store.Bindings(ctx)
|
||||
everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs})
|
||||
everything, _ := json.Marshal([]any{mm.events, mm.bindings.values, mm.licences.values, ls, bs})
|
||||
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
|
||||
if strings.Contains(string(everything), token) {
|
||||
t.Fatalf("%s was published: %s", token, everything)
|
||||
}
|
||||
}
|
||||
row, _ := mm.store.Licence(ctx, licence1)
|
||||
if strings.Contains(row.Sealed, "rt-") {
|
||||
t.Fatal("the grant is stored in the clear")
|
||||
if raw, _ := json.Marshal(row); strings.Contains(string(raw), "rt-") || strings.Contains(string(raw), "at-") {
|
||||
t.Fatalf("the licence keeps a token: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,22 +414,40 @@ func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
|
||||
// The agent module's own report shape (claude-code's holdingsOf), parsed here.
|
||||
raw := `{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription",
|
||||
"refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},
|
||||
"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":0,
|
||||
"changedAt":"2026-10-04T11:00:00.000Z"}`
|
||||
var h Holdings
|
||||
if err := json.Unmarshal([]byte(raw), &h); err != nil {
|
||||
// design 39 bus-and-switch §23, §46, B12, B22: a holdings-changed, decoded from a copy of the agent module's form, is
|
||||
// kept as its node's latest report, and one carrying a fingerprint the manager does not hold is a candidate; the
|
||||
// manager asks every node with holdings.asked, its time and who asks.
|
||||
func TestAHoldingsChangedIsHeardAndMakesACandidate(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
raw, err := os.ReadFile("testdata/holdings-changed.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heard := &Heard{}
|
||||
if _, err := heard.Take(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all := heard.All()
|
||||
if len(all) != 1 || heard.Of("laptop") != 4 {
|
||||
t.Fatalf("heard %+v", all)
|
||||
}
|
||||
h := all[0]
|
||||
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
|
||||
t.Fatalf("the report's time does not parse: %v", err)
|
||||
}
|
||||
if c, err := mm.m.CandidatesIn(context.Background(), all); err != nil || len(c["u-1"]) != 1 {
|
||||
t.Fatalf("candidates %v %v", c, err)
|
||||
}
|
||||
if _, err := heard.Take([]byte(`{"kind":"subscription"}`)); err == nil {
|
||||
t.Fatal("a report naming no node was kept")
|
||||
}
|
||||
if err := mm.m.AskHoldings(Itself); err != nil || len(mm.events) != 1 ||
|
||||
mm.events[0] != HoldingsAsked+` {"at":"`+t0.UTC().Format(time.RFC3339Nano)+`","by":"claude-licence-manager"}` {
|
||||
t.Fatalf("said %v %v", mm.events, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
|
||||
@@ -350,15 +458,15 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
|
||||
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||
if mm.state["laptop"].Licence != licence1 {
|
||||
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state)
|
||||
if mm.bound("laptop").Licence != licence1 {
|
||||
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.bindings.values)
|
||||
}
|
||||
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
|
||||
t.Fatal("the older login was exchanged")
|
||||
}
|
||||
g := mm.state["laptop"].Generation
|
||||
g := mm.bound("laptop").Generation
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||
if mm.state["laptop"].Generation != g {
|
||||
if mm.bound("laptop").Generation != g {
|
||||
t.Fatal("a node already bound was bound again")
|
||||
}
|
||||
}
|
||||
@@ -368,8 +476,8 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")})
|
||||
if mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
|
||||
t.Fatalf("%v", mm.state)
|
||||
if mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
|
||||
t.Fatalf("%v", mm.bindings.values)
|
||||
}
|
||||
mm.now = t0.Add(time.Hour)
|
||||
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
|
||||
@@ -379,11 +487,11 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
|
||||
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
|
||||
t.Fatalf("adopted %v", adopted)
|
||||
}
|
||||
if mm.state["laptop"].Licence != "acct-2@example.org" {
|
||||
t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"])
|
||||
if mm.bound("laptop").Licence != "acct-2@example.org" {
|
||||
t.Fatalf("the node a login was made on stayed bound to %v", mm.bound("laptop"))
|
||||
}
|
||||
if mm.state["server"].Licence != licence1 {
|
||||
t.Fatalf("another node moved: %v", mm.state["server"])
|
||||
if mm.bound("server").Licence != licence1 {
|
||||
t.Fatalf("another node moved: %v", mm.bound("server"))
|
||||
}
|
||||
ls, _ := mm.store.Licences(ctx)
|
||||
if len(ls) != 2 {
|
||||
@@ -396,11 +504,11 @@ func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
g := mm.state["laptop"]
|
||||
g := mm.bound("laptop")
|
||||
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1}
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")})
|
||||
if mm.state["laptop"] != g {
|
||||
t.Fatalf("a dead login moved its node: %v", mm.state["laptop"])
|
||||
if mm.bound("laptop") != g {
|
||||
t.Fatalf("a dead login moved its node: %v", mm.bound("laptop"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,9 +521,10 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
|
||||
if err != nil || r["adopted"] != true {
|
||||
t.Fatalf("%v %v", r, err)
|
||||
}
|
||||
l, _ := mm.store.Licence(ctx, "api")
|
||||
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test-0123456789abcdefghij" {
|
||||
t.Fatalf("stored %q", plain)
|
||||
for _, part := range []string{PartGrant, PartAccess} {
|
||||
if plain, _ := mm.grants.Get(ctx, "api", part); plain != "sk-ant-api03-test-0123456789abcdefghij" {
|
||||
t.Fatalf("the vault holds %q as the %s", plain, part)
|
||||
}
|
||||
}
|
||||
other, _ := GenerateKeyPair()
|
||||
wrong, _ := Seal("sk", other.PublicKey)
|
||||
@@ -439,7 +548,7 @@ func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
|
||||
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g := mm.state["laptop"].Generation; g <= 16 {
|
||||
if g := mm.bound("laptop").Generation; g <= 16 {
|
||||
t.Fatalf("the laptop applied generation 16 and was given %d", g)
|
||||
}
|
||||
}
|
||||
@@ -460,28 +569,68 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// changedSince is the binding.changed events said from mark on, decoded.
|
||||
func (mm *miniMesh) changedSince(mark int) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, e := range mm.events[mark:] {
|
||||
if rest, ok := strings.CutPrefix(e, BindingChanged+"."); ok {
|
||||
// The consumer is the event's further part (A-134); one said under another consumer's name reads as none.
|
||||
consumer, body, _ := strings.Cut(rest, " ")
|
||||
var v map[string]any
|
||||
_ = json.Unmarshal([]byte(body), &v)
|
||||
if v["consumer"] != consumer {
|
||||
v["consumer"] = "said as binding.changed." + consumer
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
|
||||
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
|
||||
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
||||
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release. design 39
|
||||
// bus-and-switch §21/AC1, §36/AC1, B7, B16: each says one binding.changed per consumer moved, with its licence's key,
|
||||
// after its key is written or deleted; a switch says one licence.switched with §27's fields.
|
||||
func TestEveryMoveSaysOneBindingChangedPerConsumer(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
var lines []string
|
||||
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
|
||||
ctx := context.Background()
|
||||
once := func(mark int, consumer, licence, by string) map[string]any {
|
||||
t.Helper()
|
||||
got := mm.changedSince(mark)
|
||||
if len(got) != 1 || got[0]["consumer"] != consumer || got[0]["licence"] != licence || got[0]["by"] != by {
|
||||
t.Fatalf("binding.changed said %v, want one for %s to %q by %s", got, consumer, licence, by)
|
||||
}
|
||||
if licence != "" && got[0]["key"] != KeyFor(licence) {
|
||||
t.Fatalf("binding.changed names the key %v", got[0]["key"])
|
||||
}
|
||||
if mm.bound(consumer).Licence != licence {
|
||||
t.Fatalf("said before the key was written: %+v", mm.bound(consumer))
|
||||
}
|
||||
return got[0]
|
||||
}
|
||||
mark := len(mm.events)
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
|
||||
if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
|
||||
t.Fatalf("a login's binding said %+v", s)
|
||||
}
|
||||
once(mark, "laptop", licence1, Itself)
|
||||
file := filepath.Join(t.TempDir(), "key")
|
||||
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
|
||||
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mark = len(mm.events)
|
||||
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
|
||||
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
||||
if e := once(mark, "laptop", "api", "g14.node-tools"); e["from"] != licence1 || e["kind"] != "api-key" || e["at"] == nil {
|
||||
t.Fatalf("the switch said %v", e)
|
||||
}
|
||||
if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" {
|
||||
t.Fatalf("the node is told %+v", s)
|
||||
}
|
||||
said := strings.Join(lines, "\n")
|
||||
@@ -489,34 +638,46 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
||||
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
|
||||
t.Fatalf("the journal said:\n%s", said)
|
||||
}
|
||||
var switched string
|
||||
var switched []string
|
||||
for _, e := range mm.events {
|
||||
if strings.HasPrefix(e, "licence.switched ") {
|
||||
switched = e
|
||||
switched = append(switched, e)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
|
||||
if !strings.Contains(switched, want) {
|
||||
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
|
||||
if len(switched) != 1 {
|
||||
t.Fatalf("licence.switched said %d times: %v", len(switched), mm.events)
|
||||
}
|
||||
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"to":"api"`, `"consumers":["laptop"]`, `"why":"the switch verb"`} {
|
||||
if !strings.Contains(switched[0], want) {
|
||||
t.Fatalf("the event %q lacks %s", switched[0], want)
|
||||
}
|
||||
}
|
||||
last := mm.store.Audits[len(mm.store.Audits)-1]
|
||||
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
|
||||
all := mm.audit.under("switched")
|
||||
if raw, _ := json.Marshal(all[len(all)-1]); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), `"to":"api"`) {
|
||||
t.Fatalf("the audit kept %s", raw)
|
||||
}
|
||||
|
||||
_, _ = mm.m.Bind(ctx, "server", licence1)
|
||||
mark = len(mm.events)
|
||||
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
|
||||
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
|
||||
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
|
||||
}
|
||||
once(mark, "server", licence1, "novox.node-tools")
|
||||
|
||||
mark = len(mm.events)
|
||||
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e := once(mark, "laptop", "", "person.operator"); e["from"] != "api" {
|
||||
t.Fatalf("the release said %v", e)
|
||||
}
|
||||
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
|
||||
t.Fatalf("the journal said:\n%s", said)
|
||||
}
|
||||
for _, e := range mm.events {
|
||||
for _, gone := range []string{"licence.bound ", "licence.released ", "licence.rotated "} {
|
||||
if strings.HasPrefix(e, gone) {
|
||||
t.Fatalf("an event no longer said was said: %s", e)
|
||||
}
|
||||
}
|
||||
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
|
||||
t.Fatalf("an event carries a secret: %s", e)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// design 39 bus-and-switch §1: the module moves to the new form whole — no `reads`, no old `state`, no `prepare`
|
||||
// step — and asks for each state it keeps by template; its two logs are in the `logs` section with their bounds, the
|
||||
// form the runtime on main issues (C3).
|
||||
func TestTheManifestKeepsNoOldForm(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, old := range []string{"reads", "state"} {
|
||||
if _, ok := m[old]; ok {
|
||||
t.Errorf("the manifest keeps the old section %q", old)
|
||||
}
|
||||
}
|
||||
var resources []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(m["resources"], &resources)
|
||||
for _, r := range resources {
|
||||
if r.ID == "prepare" {
|
||||
t.Error("the manifest keeps the run-once step prepare")
|
||||
}
|
||||
}
|
||||
var requires []json.RawMessage
|
||||
_ = json.Unmarshal(m["requires"], &requires)
|
||||
kept := map[string]string{}
|
||||
for _, r := range requires {
|
||||
var e struct{ Name, Template string }
|
||||
if json.Unmarshal(r, &e) == nil {
|
||||
kept[e.Name] = e.Template
|
||||
}
|
||||
}
|
||||
for name, template := range map[string]string{"licences": "state", "bindings": "state", "leases": "state",
|
||||
"offered": "state", "usage": "", "audit": "", "carried": "state", "conditions": "state"} {
|
||||
if kept[name] != template {
|
||||
t.Errorf("bus-state entry %s is %q in requires, want %q", name, kept[name], template)
|
||||
}
|
||||
}
|
||||
var logs []json.RawMessage
|
||||
_ = json.Unmarshal(m["logs"], &logs)
|
||||
declared := logsSection(logs)
|
||||
for name, bounds := range map[string]map[string]float64{"usage": {"max-age-days": 35, "max-mib": 256},
|
||||
"audit": {"max-mib": 64}} {
|
||||
l, ok := declared[name]
|
||||
if !ok {
|
||||
t.Errorf("the log %s is not in the logs section", name)
|
||||
continue
|
||||
}
|
||||
for k, v := range bounds {
|
||||
if l[k] != v {
|
||||
t.Errorf("the log %s has %s %v, want %v", name, k, l[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// issued is what the manager's own runtime is issued from its manifest (design 39 bus-and-switch C1): its account, the
|
||||
// states it keeps (`requires` by template), the logs it keeps (the `logs` section, the form the runtime on main issues,
|
||||
// C3; a log asked for by template in `requires` is issued nothing), what it may ask (`invokes`) and say (`emits`).
|
||||
type issued struct {
|
||||
RunsAs, SecretsOwner string
|
||||
OwnSecrets map[string]string
|
||||
Resources []map[string]any
|
||||
States, Logs map[string]bool
|
||||
Invokes, Emits map[string]bool
|
||||
}
|
||||
|
||||
func manifestIssued(t testing.TB) issued {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
RunsAs string `json:"runs-as"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
OwnSecrets map[string]string `json:"own-secrets"`
|
||||
Requires []json.RawMessage `json:"requires"`
|
||||
Logs []json.RawMessage `json:"logs"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Emits []string `json:"emits"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := issued{RunsAs: m.RunsAs, SecretsOwner: m.SecretsOwner, OwnSecrets: m.OwnSecrets, Resources: m.Resources,
|
||||
States: map[string]bool{}, Logs: map[string]bool{}, Invokes: map[string]bool{}, Emits: map[string]bool{}}
|
||||
for _, r := range m.Requires {
|
||||
var e struct{ Name, Storage string }
|
||||
if json.Unmarshal(r, &e) != nil {
|
||||
continue
|
||||
}
|
||||
if e.Storage == "key-value" {
|
||||
out.States[e.Name] = true
|
||||
}
|
||||
}
|
||||
for name := range logsSection(m.Logs) {
|
||||
out.Logs[name] = true
|
||||
}
|
||||
for _, k := range m.Invokes {
|
||||
out.Invokes[k] = true
|
||||
}
|
||||
for _, e := range m.Emits {
|
||||
out.Emits[e] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// logsSection is the manifest's `logs` section by name: each a name alone or {name, max-age-days, max-mib}.
|
||||
func logsSection(logs []json.RawMessage) map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, l := range logs {
|
||||
var name string
|
||||
if json.Unmarshal(l, &name) == nil {
|
||||
out[name] = map[string]any{}
|
||||
continue
|
||||
}
|
||||
var e map[string]any
|
||||
if json.Unmarshal(l, &e) == nil {
|
||||
if n, _ := e["name"].(string); n != "" {
|
||||
out[n] = e
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mayAsk is whether the manager's own account may ask key: a module tool asked on one node (`<module>.<tool>@<node>`)
|
||||
// is invoked as `<module>.<tool>`.
|
||||
// emits is whether the manifest names the event key's family: the key itself, or a family it extends by further parts,
|
||||
// as binding.changed.<consumer> extends binding.changed (design 47 G A-134).
|
||||
func (i issued) emits(key string) bool {
|
||||
for k := key; k != ""; {
|
||||
if i.Emits[k] {
|
||||
return true
|
||||
}
|
||||
dot := strings.LastIndex(k, ".")
|
||||
if dot < 0 {
|
||||
break
|
||||
}
|
||||
k = k[:dot]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (i issued) mayAsk(key string) bool {
|
||||
k, _, _ := strings.Cut(key, "@")
|
||||
return i.Invokes[k]
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch C1: the manager runs as an account of its own, which it makes and which owns what it places
|
||||
// on its machine and its secrets, reaching the bus as itself; and that account is issued every ask its code makes,
|
||||
// since its runtime is not the machine's tool runner.
|
||||
func TestTheManagerRunsAsItsOwnAccount(t *testing.T) {
|
||||
m := manifestIssued(t)
|
||||
if m.RunsAs != Publisher {
|
||||
t.Fatalf("the manager runs as %q, not as its own account %s", m.RunsAs, Publisher)
|
||||
}
|
||||
if m.SecretsOwner != Publisher || m.OwnSecrets["broker"] == "" {
|
||||
t.Errorf("its secrets belong to %q and its own broker secret is %q: both are its account's", m.SecretsOwner,
|
||||
m.OwnSecrets["broker"])
|
||||
}
|
||||
made := false
|
||||
for _, r := range m.Resources {
|
||||
switch r["type"] {
|
||||
case "user":
|
||||
made = made || r["name"] == Publisher
|
||||
case "directory", "file":
|
||||
if r["owner"] != Publisher {
|
||||
t.Errorf("resource %v is owned by %v, not by the manager's account", r["id"], r["owner"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !made {
|
||||
t.Errorf("the manager does not make its account: no user resource named %s", Publisher)
|
||||
}
|
||||
files, _ := filepath.Glob("../*/*.go")
|
||||
shared, _ := filepath.Glob("../../internal/*/*.go")
|
||||
files = append(files, shared...)
|
||||
asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`)
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range asked.FindAllStringSubmatch(string(src), -1) {
|
||||
if !m.mayAsk(k[1]) {
|
||||
t.Errorf("%s asks %s, which the manifest's invokes do not name", f, k[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 parts §9, §10: the carry-over has left the module whole. Its manifest requires neither the store database
|
||||
// nor the secret grant-key, contributes and binds nothing and has no secrets section; no word of its code names the
|
||||
// database's address or grant.key; its go.mod requires no postgres driver; the tool licences_carried is gone, and the
|
||||
// state carried stays as the carry-over's record.
|
||||
func TestTheCarryOverHasLeft(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, gone := range []string{"contributes", "binds", "secrets"} {
|
||||
if _, ok := m[gone]; ok {
|
||||
t.Errorf("the manifest keeps %q", gone)
|
||||
}
|
||||
}
|
||||
var requires []json.RawMessage
|
||||
_ = json.Unmarshal(m["requires"], &requires)
|
||||
carried := false
|
||||
for _, r := range requires {
|
||||
var bare string
|
||||
if json.Unmarshal(r, &bare) == nil {
|
||||
t.Errorf("the manifest requires %q", bare)
|
||||
}
|
||||
var e struct{ Name string }
|
||||
if json.Unmarshal(r, &e) == nil && e.Name == "carried" {
|
||||
carried = true
|
||||
}
|
||||
}
|
||||
if !carried {
|
||||
t.Error("the state carried, the carry-over's record, is gone")
|
||||
}
|
||||
var tools []string
|
||||
_ = json.Unmarshal(m["tools"], &tools)
|
||||
if slices.Contains(tools, "licences_carried") {
|
||||
t.Error("the manifest keeps the tool licences_carried")
|
||||
}
|
||||
var resources []struct{ ID string }
|
||||
_ = json.Unmarshal(m["resources"], &resources)
|
||||
for _, r := range resources {
|
||||
if r.ID == "database-url" {
|
||||
t.Error("the manifest keeps the resource database-url")
|
||||
}
|
||||
}
|
||||
words := []string{"DATABASE_URL" + "_FILE", "MESH_LICENCE_KEY" + "_FILE"}
|
||||
err = filepath.WalkDir(moduleDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
switch filepath.Ext(path) {
|
||||
case ".go", ".json", ".mod", ".sum":
|
||||
default:
|
||||
if filepath.Base(filepath.Dir(path)) != "files" {
|
||||
return nil // a built binary is no code of the module
|
||||
}
|
||||
}
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range words {
|
||||
if strings.Contains(string(src), w) {
|
||||
t.Errorf("%s names %s", path, w)
|
||||
}
|
||||
}
|
||||
if filepath.Base(path) == "go.mod" && strings.Contains(string(src), "github.com/jackc/pgx") {
|
||||
t.Errorf("%s requires the postgres driver", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 parts §1 to §4, §6 to §8: the module is four parts in one manifest. Its artifacts are exactly the manager
|
||||
// bundle, the tools bundle and the templates archive, with their words; its resources exactly the account, its
|
||||
// directory and the settings file, in that order; no process or container resource, no run-once step, no bus account
|
||||
// of its own and no runtime-health; its tools are the four reading ones and the three acting ones; files/ holds only
|
||||
// the settings template.
|
||||
func TestTheModuleIsFourParts(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
same := func(what string, got, want any) {
|
||||
t.Helper()
|
||||
g, _ := json.Marshal(got)
|
||||
w, _ := json.Marshal(want)
|
||||
if string(g) != string(w) {
|
||||
t.Errorf("%s is %s, want %s", what, g, w)
|
||||
}
|
||||
}
|
||||
build, _ := m["build"].(map[string]any)
|
||||
same("build.artifacts", build["artifacts"], []map[string]any{
|
||||
{"name": "manager", "kind": "bundle", "language": "go", "system": "arch", "from": "cmd/manager",
|
||||
"binary": "claude-licence-manager", "loads": []string{"claude-licence-manager"},
|
||||
"env": map[string]string{"MESH_LICENCE_STATE": "${dir:state}", "MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"}},
|
||||
{"name": "tools", "kind": "bundle", "language": "go", "system": "arch", "from": "cmd/tools",
|
||||
"binary": "claude-licence-manager-tools", "loads": []string{"claude-licence-manager-tools"}},
|
||||
{"name": "files", "kind": "archive", "from": "files"},
|
||||
})
|
||||
same("resources", m["resources"], []map[string]any{
|
||||
{"id": "account", "type": "user", "name": Publisher, "shell": "/usr/bin/nologin", "home": "/var/lib/" + Publisher},
|
||||
{"id": "state", "type": "directory", "mode": "0700", "owner": Publisher, "place": ".", "unknown": "program"},
|
||||
{"id": "settings", "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", "owner": Publisher,
|
||||
"takes-config": []string{"cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours",
|
||||
"refresh_warn_days", "switch_auto", "switch_at_pct", "room_below_pct", "warn_at_pct", "usage_minutes",
|
||||
"usage_stale_minutes"}, "merge": "json", "template": "files/settings.json"},
|
||||
})
|
||||
tools, _ := m["tools"].([]any)
|
||||
var names []string
|
||||
for _, n := range tools {
|
||||
names = append(names, fmt.Sprint(n))
|
||||
}
|
||||
sort.Strings(names)
|
||||
same("tools", names, []string{"licences_conditions", "licences_kept", "licences_read_usage", "licences_resync",
|
||||
"licences_settings", "licences_switch_now", "licences_switches"})
|
||||
same("runs-as", m["runs-as"], Publisher)
|
||||
same("secrets-owner", m["secrets-owner"], Publisher)
|
||||
same("own-secrets", m["own-secrets"], map[string]string{"broker": "${dir:state}/broker"})
|
||||
if _, ok := m["runtime-health"]; ok {
|
||||
t.Error("the manifest adds runtime-health: the node-engine's judgement that the runtime is alive is its health")
|
||||
}
|
||||
var walk func(path string, v any)
|
||||
walk = func(path string, v any) {
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
for k, e := range v {
|
||||
if k == "bus" || k == "run-once" {
|
||||
t.Errorf("the manifest says %s at %s", k, path)
|
||||
}
|
||||
if k == "type" && (e == "process" || e == "container") {
|
||||
t.Errorf("the manifest has a %v resource at %s", e, path)
|
||||
}
|
||||
walk(path+"."+k, e)
|
||||
}
|
||||
case []any:
|
||||
for i, e := range v {
|
||||
walk(fmt.Sprintf("%s[%d]", path, i), e)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("", m)
|
||||
files, _ := os.ReadDir(filepath.Join(moduleDir, "files"))
|
||||
var templates []string
|
||||
for _, f := range files {
|
||||
templates = append(templates, f.Name())
|
||||
}
|
||||
same("files/", templates, []string{"settings.json"})
|
||||
}
|
||||
|
||||
// design 39 parts §2, §4: the manager bundle registers the seat's ten verbs under the seat and the three acting tools
|
||||
// under the module, and nothing more.
|
||||
func TestTheManagerRegistersTheVerbsAndTheActs(t *testing.T) {
|
||||
var names []string
|
||||
for _, tool := range tools() {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var want []string
|
||||
for _, v := range []string{"licences", "bindings", "bind", "switch", "release", "refresh", "usage", "adopt",
|
||||
"current", "public-key"} {
|
||||
want = append(want, Seat+"."+v)
|
||||
}
|
||||
want = append(want, "licences_read_usage", "licences_resync", "licences_switch_now")
|
||||
sort.Strings(want)
|
||||
if !slices.Equal(names, want) {
|
||||
t.Fatalf("the manager bundle registers %v, not %v", names, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
// A licence's room (design 39 bus-and-switch §25, §48, C4): 100 less its weekly percentage in its latest reading, the
|
||||
// session window taking no part (the vendor retired the session limit), known only for a subscription read successfully within usage_stale_minutes, whose refresh has not
|
||||
// failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known,
|
||||
// so it is never a target.
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// Room is a licence's room at now, and whether it is known.
|
||||
func Room(l Licence, now time.Time, s Settings) (float64, bool) {
|
||||
if l.Kind != "subscription" || l.Usage == nil || l.Usage.WeeklyPct == nil {
|
||||
return 0, false
|
||||
}
|
||||
read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt)
|
||||
if err != nil || now.Sub(read) > s.UsageStale {
|
||||
return 0, false
|
||||
}
|
||||
if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) {
|
||||
return 0, false
|
||||
}
|
||||
return 100 - *l.Usage.WeeklyPct, true
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// vendorUsage is the vendor's usage answer as it reads: three windows, each with its reset time.
|
||||
var vendorUsage = map[string]any{
|
||||
"five_hour": map[string]any{"utilization": 41.0, "resets_at": "2026-11-11T23:00:00Z"},
|
||||
"seven_day": map[string]any{"utilization": 97.0, "resets_at": "2026-11-14T09:00:00Z"},
|
||||
"seven_day_sonnet": map[string]any{"utilization": 12.0, "resets_at": "2026-11-15T09:00:00Z"},
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §24/AC1: a vendor answer is flattened with its three reset times, kept beside the licence
|
||||
// with when it was read, and said as usage.read with those fields.
|
||||
func TestAReadingIsFlattenedWithThreeResets(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
mm.vendor.usage = vendorUsage
|
||||
mm.events = nil
|
||||
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, _ := mm.store.Licence(ctx, licence1)
|
||||
u := l.Usage
|
||||
if u == nil || *u.SessionPct != 41 || u.SessionResetsAt != "2026-11-11T23:00:00Z" || *u.WeeklyPct != 97 ||
|
||||
u.WeeklyResetsAt != "2026-11-14T09:00:00Z" || *u.SonnetPct != 12 || u.SonnetResetsAt != "2026-11-15T09:00:00Z" ||
|
||||
u.ReadAt != t0.UTC().Format(time.RFC3339Nano) || u.ReadError != "" {
|
||||
t.Fatalf("kept %+v", u)
|
||||
}
|
||||
said := strings.Join(mm.events, "\n")
|
||||
for _, want := range []string{`"weeklyResetsAt":"2026-11-14T09:00:00Z"`, `"sonnetResetsAt":"2026-11-15T09:00:00Z"`,
|
||||
`"sessionResetsAt":"2026-11-11T23:00:00Z"`} {
|
||||
if !strings.Contains(said, "usage.read ") || !strings.Contains(said, want) {
|
||||
t.Fatalf("said %s, lacking %s", said, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §24/AC2: a failed answer keeps the last percentages and their time, writes the vendor's
|
||||
// status and reason, and appends no reading.
|
||||
func TestAFailedReadingKeepsThePercentages(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
mm.vendor.usage = vendorUsage
|
||||
_ = mm.m.ReadUsage(ctx)
|
||||
mm.now = t0.Add(5 * time.Minute)
|
||||
mm.vendor.usageErr = errors.New(`the usage endpoint answered 429: {"error":"rate_limited"}`)
|
||||
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, _ := mm.store.Licence(ctx, licence1)
|
||||
u := l.Usage
|
||||
if u == nil || *u.WeeklyPct != 97 || u.ReadAt != t0.UTC().Format(time.RFC3339Nano) ||
|
||||
u.ReadError != `the usage endpoint answered 429: {"error":"rate_limited"}` {
|
||||
t.Fatalf("kept %+v", u)
|
||||
}
|
||||
if n := len(mm.usage.entries); n != 1 {
|
||||
t.Fatalf("a failed reading appended: %d entries", n)
|
||||
}
|
||||
// §16, §31: a grant the vault does not answer is a failed reading, written and gone past, never the end of the round.
|
||||
mm.vendor.usageErr = nil
|
||||
mm.grants.mu.Lock()
|
||||
delete(mm.grants.held, SecretName(KeyFor(licence1), PartGrant))
|
||||
mm.grants.mu.Unlock()
|
||||
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||
t.Fatalf("a grant the vault did not answer ended the round: %v", err)
|
||||
}
|
||||
l, _ = mm.store.Licence(ctx, licence1)
|
||||
if !strings.Contains(l.Usage.ReadError, "cannot be read from the vault") || *l.Usage.WeeklyPct != 97 {
|
||||
t.Fatalf("after the vault's refusal the reading is %+v", l.Usage)
|
||||
}
|
||||
// §4, §16: a reading is taken under the licence's lease; with another run holding it, nothing is read or written.
|
||||
if ok, _ := mm.store.Lease(ctx, "licence."+KeyFor(licence1), "another run", time.Minute); !ok {
|
||||
t.Fatal("the lease was not taken")
|
||||
}
|
||||
reads, err := mm.m.ReadUsageOf(ctx, licence1, true)
|
||||
if err != nil || len(reads) != 1 || reads[0].Error != "another run holds its lease" {
|
||||
t.Fatalf("a reading under another's lease answered %+v, %v", reads, err)
|
||||
}
|
||||
if again, _ := mm.store.Licence(ctx, licence1); again.Usage.ReadError != l.Usage.ReadError {
|
||||
t.Fatalf("a reading under another's lease wrote %+v", again.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §25/AC1, §48, C4: a table of readings, ages and healths and the room each gives.
|
||||
func TestTheRoomOfEachLicence(t *testing.T) {
|
||||
now := t0
|
||||
pct := func(v float64) *float64 { return &v }
|
||||
read := func(session, weekly float64, ago time.Duration) *UsageReading {
|
||||
return &UsageReading{SessionPct: pct(session), WeeklyPct: pct(weekly), ReadAt: now.Add(-ago).Format(time.RFC3339Nano)}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
l Licence
|
||||
room float64
|
||||
known bool
|
||||
}{
|
||||
{"the weekly window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true},
|
||||
{"the session takes no part (C4)", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 80, true},
|
||||
{"no session window, weekly 33 (C4)", Licence{Kind: "subscription", Usage: &UsageReading{WeeklyPct: pct(33),
|
||||
ReadAt: now.Format(time.RFC3339Nano)}}, 67, true},
|
||||
{"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true},
|
||||
{"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false},
|
||||
{"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading {
|
||||
u := read(10, 30, 5*time.Minute)
|
||||
u.ReadError = "the usage endpoint answered 500: x"
|
||||
return u
|
||||
}()}, 70, true},
|
||||
{"never read", Licence{Kind: "subscription"}, 0, false},
|
||||
{"no weekly window", Licence{Kind: "subscription", Usage: &UsageReading{SessionPct: pct(10),
|
||||
ReadAt: now.Format(time.RFC3339Nano)}}, 0, false},
|
||||
{"refresh failing", Licence{Kind: "subscription", Failures: 3, Usage: read(10, 10, time.Minute)}, 0, false},
|
||||
{"refresh failed twice", Licence{Kind: "subscription", Failures: 2, Usage: read(10, 10, time.Minute)}, 90, true},
|
||||
{"refresh token expired", Licence{Kind: "subscription", RefreshExpiresAt: now.UnixMilli(),
|
||||
Usage: read(10, 10, time.Minute)}, 0, false},
|
||||
{"an API key", Licence{Kind: "api-key", Usage: read(0, 0, 0)}, 0, false},
|
||||
} {
|
||||
room, known := Room(c.l, now, Defaults)
|
||||
if room != c.room || known != c.known {
|
||||
t.Errorf("%s: room %v known %v, want %v %v", c.name, room, known, c.room, c.known)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A box the agent module's TypeScript sealed opens here: the two implementations are one format.
|
||||
func TestABoxSealedInTypeScriptOpensInGo(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/sealed-by-typescript.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f struct {
|
||||
PrivateKey string `json:"privateKey"`
|
||||
Box SealedBox `json:"box"`
|
||||
Plaintext string `json:"plaintext"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Open(f.Box, f.PrivateKey)
|
||||
if err != nil || got != f.Plaintext {
|
||||
t.Fatalf("opened %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// What Go seals opens with its own key and no other.
|
||||
func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
|
||||
a, _ := GenerateKeyPair()
|
||||
b, _ := GenerateKeyPair()
|
||||
box, err := Seal("a token", a.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" {
|
||||
t.Fatalf("opened %q, %v", got, err)
|
||||
}
|
||||
if _, err := Open(box, b.PrivateKey); err == nil {
|
||||
t.Fatal("a box opened for another key")
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch C2: a key pair the manager finds and cannot read (left by another account, here mode 000) is
|
||||
// replaced in its place by a new one, said in its journal; the new one is read back by the next start.
|
||||
func TestAKeyPairItCannotReadIsMadeAnew(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Fatal("run as root, a file of mode 000 is still read: this test needs an ordinary account")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "manager-key.json")
|
||||
if err := os.WriteFile(file, []byte(`{"publicKey":"old","privateKey":"old"}`), 0o000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var said []string
|
||||
log := func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }
|
||||
k, err := keypair(dir, log)
|
||||
if err != nil {
|
||||
t.Fatalf("a key pair it cannot read stops the manager: %v", err)
|
||||
}
|
||||
if k.PublicKey == "" || k.PublicKey == "old" || len(said) != 1 || !strings.Contains(said[0], "manager-key.json") {
|
||||
t.Fatalf("not made anew and said: key %q, journal %q", k.PublicKey, said)
|
||||
}
|
||||
again, err := keypair(dir, log)
|
||||
if err != nil || again != k || len(said) != 1 {
|
||||
t.Fatalf("the new pair is not the one read at the next start: %v, %v, %q", again == k, err, said)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
// The manager's settings (design 39 §8, design 39 bus-and-switch §33): today's five and the six of the switch, each
|
||||
// declared in the manifest's `config` with its default and bound, set through mesh-config and written into the
|
||||
// settings file. They are read again at every reading, so a change takes effect at the next one, and each change of a
|
||||
// value read is written in the audit under `settings`.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// settings are those in force.
|
||||
func (m *Manager) settings() Settings {
|
||||
m.settingsMu.RLock()
|
||||
defer m.settingsMu.RUnlock()
|
||||
return m.Settings
|
||||
}
|
||||
|
||||
// ReadSettings reads the settings file again, at every reading (§33): each value that changed is written in the audit
|
||||
// under `settings`, its value before and now, and the settings read are those in force from then on.
|
||||
func (m *Manager) ReadSettings(ctx context.Context) error {
|
||||
if m.SettingsFile == "" {
|
||||
return nil
|
||||
}
|
||||
next := SettingsFrom(m.SettingsFile)
|
||||
m.settingsMu.Lock()
|
||||
before := m.Settings
|
||||
m.Settings = next
|
||||
m.settingsMu.Unlock()
|
||||
was, now := before.Values(), next.Values()
|
||||
changed := map[string]any{}
|
||||
for k, v := range now {
|
||||
if !reflect.DeepEqual(was[k], v) {
|
||||
changed[k] = map[string]any{"from": was[k], "to": v}
|
||||
}
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m.record(ctx, "settings", fmt.Sprintf("%d setting(s) changed: %v", len(changed), changed), changed)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// design 39 bus-and-switch §33: the settings are read again at every reading, and each change of a value read is
|
||||
// written once in the audit under settings.
|
||||
func TestASettingChangedIsReadAtTheNextReadingAndAudited(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
mm.m.SettingsFile = filepath.Join(t.TempDir(), "settings.json")
|
||||
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 99}`), 0o600)
|
||||
if err := mm.m.ReadUsage(ctx); err != nil || len(mm.audit.under("settings")) != 0 {
|
||||
t.Fatalf("an unchanged file audited %v (%v)", mm.audit.under("settings"), err)
|
||||
}
|
||||
_ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 95, "usage_minutes": 10}`), 0o600)
|
||||
_ = mm.m.ReadUsage(ctx)
|
||||
_ = mm.m.ReadUsage(ctx)
|
||||
if s := mm.m.settings(); s.SwitchAt != 95 || s.UsageEvery.Minutes() != 10 {
|
||||
t.Fatalf("in force %+v", s)
|
||||
}
|
||||
got := mm.audit.under("settings")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("audited %v", got)
|
||||
}
|
||||
if c, _ := got[0]["switch_at_pct"].(map[string]any); c["from"] != 99.0 || c["to"] != 95.0 {
|
||||
t.Fatalf("audited %v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
// The manager's store (novox/hq ADR 0183, design 39 bus-and-switch §2 to §7): licences, bindings with a generation,
|
||||
// leases, what became of each login it was offered, usage readings and the audit, all in the bus's own storage
|
||||
// (busstore.go). Every rule is tested on the same store over a bus kept in memory.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// Binding is one consumer's binding (ADR 0206, §3): the licence, kind and generation it should hold, since when, and
|
||||
// why and by whom it was moved last.
|
||||
type Binding struct {
|
||||
Consumer string `json:"consumer"`
|
||||
Licence string `json:"licence"`
|
||||
// Key is the licence's key (§10), answered by the verb bindings (B25); the store keeps none.
|
||||
Key string `json:"key,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Generation int64 `json:"generation"`
|
||||
Since string `json:"since,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// Move is what moves a binding: the licence's kind, why and by whom, and the floor a consumer's generation must pass
|
||||
// (B11: the generation its node last reported); a nil Floor is none.
|
||||
type Move struct {
|
||||
Kind, Why, By string
|
||||
Floor func(consumer string) int64
|
||||
}
|
||||
|
||||
// floor is the floor m gives consumer.
|
||||
func (m Move) floor(consumer string) int64 {
|
||||
if m.Floor == nil {
|
||||
return 0
|
||||
}
|
||||
return m.Floor(consumer)
|
||||
}
|
||||
|
||||
// UsageRow is one usage reading.
|
||||
type UsageRow struct {
|
||||
Licence string `json:"licence"`
|
||||
At int64 `json:"at"`
|
||||
Reading UsageReading `json:"reading"`
|
||||
// Raw is the vendor's answer whole, answered only when asked (§35).
|
||||
Raw map[string]any `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
// Store is what the manager keeps.
|
||||
type Store interface {
|
||||
Licences(ctx context.Context) ([]Licence, error)
|
||||
Licence(ctx context.Context, name string) (*Licence, error)
|
||||
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
|
||||
SaveLicence(ctx context.Context, l Licence) error
|
||||
Bindings(ctx context.Context) ([]Binding, error)
|
||||
Binding(ctx context.Context, consumer string) (*Binding, error)
|
||||
// Bind binds (or switches) a consumer at its next generation.
|
||||
Bind(ctx context.Context, consumer, licence string, m Move) (Binding, error)
|
||||
Unbind(ctx context.Context, consumer string) (bool, error)
|
||||
// Advance gives every consumer of a licence its next generation: what a rotation is to them.
|
||||
Advance(ctx context.Context, licence string, m Move) ([]Binding, error)
|
||||
// Lease takes a lease for d, or answers false while another holder's is live.
|
||||
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
|
||||
Unlease(ctx context.Context, key, holder string) error
|
||||
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
|
||||
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
|
||||
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
|
||||
// Usage is the latest readings, newest first, of one licence or every one: at most limit, none before since (zero
|
||||
// is none), the vendor's answer with raw (§35).
|
||||
Usage(ctx context.Context, licence string, since time.Time, limit int, raw bool) ([]UsageRow, error)
|
||||
// Audit appends one entry for an act; an error is the act failing (B20).
|
||||
Audit(ctx context.Context, what string, detail map[string]any) error
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
|
||||
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
|
||||
in := int64(60)
|
||||
kept := NextGrant(prev, tokenResponse{AccessToken: "a2", ExpiresIn: &in}, 1000)
|
||||
if !kept.OK || kept.Grant.RefreshToken != "r" || kept.Grant.ExpiresAt != 61_000 {
|
||||
t.Fatalf("%+v", kept)
|
||||
}
|
||||
rotated := NextGrant(prev, tokenResponse{AccessToken: "a3", RefreshToken: "r2"}, 0)
|
||||
if rotated.Grant.RefreshToken != "r2" {
|
||||
t.Fatalf("%+v", rotated)
|
||||
}
|
||||
if NextGrant(prev, tokenResponse{}, 0).OK {
|
||||
t.Fatal("an answer with no access token was a grant")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
// The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and
|
||||
// L, with one or more consumers bound to it, is at or above switch_at_pct in its weekly window (C4: the session window takes
|
||||
// no part), every consumer bound to L moves, in one act, to the other licence with known room whose weekly window is
|
||||
// below room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a
|
||||
// binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off
|
||||
// a licence at its threshold, so none flaps between two.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// ByUsage is who moves a consumer by the rule (§26).
|
||||
const ByUsage = "usage"
|
||||
|
||||
// Switch is one act of the rule: from L to the target, the consumers it moves, the window of L that reached its
|
||||
// threshold, its percentage and reset, and the target's room.
|
||||
type Switch struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Consumers []string `json:"consumers"`
|
||||
Window string `json:"window"`
|
||||
Pct float64 `json:"pct"`
|
||||
ResetsAt string `json:"resetsAt"`
|
||||
Room float64 `json:"room"`
|
||||
}
|
||||
|
||||
// why is the cause a moved binding carries: L, the window, its percentage and its reset (§26).
|
||||
func (s Switch) why() string {
|
||||
return fmt.Sprintf("%s reached %g %% of its %s window, which resets at %s", s.From, s.Pct, s.Window, s.ResetsAt)
|
||||
}
|
||||
|
||||
// fields are the act's fields, the same in licence.switched and the audit entry switched (§27).
|
||||
func (s Switch) fields() map[string]any {
|
||||
f := switchedFields(s.From, s.To, s.Consumers, Cause{Why: s.why(), By: ByUsage})
|
||||
f["window"], f["pct"], f["resetsAt"], f["room"] = s.Window, s.Pct, s.ResetsAt, s.Room
|
||||
return f
|
||||
}
|
||||
|
||||
// Rule is what the rule finds for one licence at its threshold: the act, or none with NoTarget when no other licence
|
||||
// has room; Over is then the licence's consumers and the window at its threshold, for the condition no-room (§29).
|
||||
type Rule struct {
|
||||
Switch *Switch
|
||||
NoTarget bool
|
||||
Over *Switch
|
||||
}
|
||||
|
||||
// consumersOf are the consumers bound to licence, sorted.
|
||||
func consumersOf(licence string, bindings []Binding) []string {
|
||||
var consumers []string
|
||||
for _, b := range bindings {
|
||||
if b.Licence == licence {
|
||||
consumers = append(consumers, b.Consumer)
|
||||
}
|
||||
}
|
||||
sort.Strings(consumers)
|
||||
return consumers
|
||||
}
|
||||
|
||||
// windowAt is l's weekly window when it is at or above pct (C4: the session window takes no part): its name,
|
||||
// percentage and reset; ok false when it is not.
|
||||
func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) {
|
||||
if u == nil || u.WeeklyPct == nil || *u.WeeklyPct < pct {
|
||||
return "", 0, "", false
|
||||
}
|
||||
return "weekly", *u.WeeklyPct, u.WeeklyResetsAt, true
|
||||
}
|
||||
|
||||
// targetFor is the licence §26 moves l's consumers to: among the others with known room whose weekly window is below
|
||||
// room_below_pct (C4), the one with the most room, the first by name on a tie; empty when none has room.
|
||||
func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) {
|
||||
others := append([]Licence(nil), licences...)
|
||||
sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name })
|
||||
to, best := "", -1.0
|
||||
for _, o := range others {
|
||||
if o.Name == l.Name {
|
||||
continue
|
||||
}
|
||||
room, known := Room(o, m.Now(), s)
|
||||
if !known || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best {
|
||||
continue
|
||||
}
|
||||
best, to = room, o.Name
|
||||
}
|
||||
return to, best
|
||||
}
|
||||
|
||||
// RuleFor is what §26 gives for licence l among every licence and binding, under the settings s. A licence below its
|
||||
// threshold, or with no consumer, gives nothing.
|
||||
func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s Settings) Rule {
|
||||
consumers := consumersOf(l.Name, bindings)
|
||||
if len(consumers) == 0 {
|
||||
return Rule{}
|
||||
}
|
||||
sw := Switch{From: l.Name, Consumers: consumers}
|
||||
var ok bool
|
||||
if sw.Window, sw.Pct, sw.ResetsAt, ok = windowAt(l.Usage, s.SwitchAt); !ok {
|
||||
return Rule{}
|
||||
}
|
||||
if sw.To, sw.Room = m.targetFor(l, licences, s); sw.To == "" {
|
||||
sw.Room = 0
|
||||
return Rule{NoTarget: true, Over: &sw}
|
||||
}
|
||||
return Rule{Switch: &sw}
|
||||
}
|
||||
|
||||
// ApplyRule applies §26 to one licence, or to every one when licence is empty: after a reading, by switch_auto, or by
|
||||
// hand, even with switch_auto off (§32, §39). With dry it answers the moves and moves nothing. An invalid pair of
|
||||
// settings moves no consumer (§33).
|
||||
func (m *Manager) ApplyRule(ctx context.Context, licence string, byHand, dry bool) ([]Switch, error) {
|
||||
s := m.settings()
|
||||
if (!s.SwitchAuto && !byHand) || len(s.Invalid()) > 0 {
|
||||
return nil, nil
|
||||
}
|
||||
licences, err := m.Store.Licences(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindings, err := m.Store.Bindings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var acts []Switch
|
||||
for _, l := range licences {
|
||||
if licence != "" && l.Name != licence {
|
||||
continue
|
||||
}
|
||||
r := m.RuleFor(l, licences, bindings, s)
|
||||
if r.Switch == nil {
|
||||
continue
|
||||
}
|
||||
acts = append(acts, *r.Switch)
|
||||
if dry {
|
||||
continue
|
||||
}
|
||||
if err := m.act(ctx, *r.Switch, licences); err != nil {
|
||||
return acts, err
|
||||
}
|
||||
// What was moved is bound to the target now, for the next licence's rule.
|
||||
if bindings, err = m.Store.Bindings(ctx); err != nil {
|
||||
return acts, err
|
||||
}
|
||||
}
|
||||
return acts, nil
|
||||
}
|
||||
|
||||
// act moves every consumer of one act as the switch verb does, then says the act once and tells the operator (§26 to
|
||||
// §28).
|
||||
func (m *Manager) act(ctx context.Context, sw Switch, licences []Licence) error {
|
||||
kind := ""
|
||||
for _, l := range licences {
|
||||
if l.Name == sw.To {
|
||||
kind = l.Kind
|
||||
}
|
||||
}
|
||||
floor := m.floorIn(nil)
|
||||
for _, consumer := range sw.Consumers {
|
||||
b, err := m.Store.Bind(ctx, consumer, sw.To, Move{Kind: kind, Why: sw.why(), By: ByUsage, Floor: floor})
|
||||
if err != nil {
|
||||
return fmt.Errorf("moving %s to %s: %w", consumer, sw.To, err)
|
||||
}
|
||||
m.changed(b, sw.From)
|
||||
}
|
||||
f := sw.fields()
|
||||
if err := m.record(ctx, "switched", fmt.Sprintf("switched %s from %s to %s: %s; by %s",
|
||||
strings.Join(sw.Consumers, ", "), sw.From, sw.To, sw.why(), ByUsage), f); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.Emit("licence.switched", f)
|
||||
if m.Tell != nil {
|
||||
line := fmt.Sprintf("%s; %s has %g %% room", sw.why(), sw.To, sw.Room)
|
||||
if err := m.Tell(ctx, strings.Join(sw.Consumers, ", ")+" moved to "+sw.To, []string{line}); err != nil {
|
||||
m.Log("the switch of %s to %s could not be told: %v", strings.Join(sw.Consumers, ", "), sw.To, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// read is a licence's latest reading, read at t0.
|
||||
func read(session, weekly float64) *UsageReading {
|
||||
return &UsageReading{SessionPct: &session, SessionResetsAt: "2026-11-11T23:00:00Z", WeeklyPct: &weekly,
|
||||
WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
|
||||
// weekly is a reading with no session window, as the vendor answers since the session limit was retired (C4).
|
||||
func weekly(pct float64) *UsageReading {
|
||||
u := read(0, pct)
|
||||
u.SessionPct, u.SessionResetsAt = nil, ""
|
||||
return u
|
||||
}
|
||||
|
||||
func sub(name string, u *UsageReading) Licence {
|
||||
return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §26/AC1, §25, §48, C4: a table of licences, readings and bindings and the moves each gives.
|
||||
func TestTheRuleMovesEachTable(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
on := func(consumers ...string) []Binding {
|
||||
var out []Binding
|
||||
for _, c := range consumers {
|
||||
out = append(out, Binding{Consumer: c, Licence: "a"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
licences []Licence
|
||||
bindings []Binding
|
||||
to string
|
||||
window string
|
||||
noTarget bool
|
||||
consumers int
|
||||
}{
|
||||
{"below its threshold", []Licence{sub("a", read(50, 98)), sub("b", read(0, 0))}, on("x"), "", "", false, 0},
|
||||
{"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0},
|
||||
{"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))},
|
||||
on("x", "y", "z", "w"), "c", "weekly", false, 4},
|
||||
{"the session window at 100 moves nothing (C4)", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))},
|
||||
on("x"), "", "", false, 0},
|
||||
{"no session window, weekly 99, to weekly 33 (C4)", []Licence{sub("a", weekly(99)), sub("b", weekly(33))},
|
||||
on("x", "y"), "b", "weekly", false, 2},
|
||||
{"a tie goes to the first by name", []Licence{sub("a", read(0, 99)), sub("c", read(10, 10)), sub("b", read(10, 10))},
|
||||
on("x"), "b", "weekly", false, 1},
|
||||
{"a target's weekly below room_below_pct, its session not counted (C4)", []Licence{sub("a", read(0, 99)),
|
||||
sub("b", read(0, 90)), sub("c", read(95, 89))}, on("x"), "c", "weekly", false, 1},
|
||||
{"no other licence has room", []Licence{sub("a", read(0, 99)), sub("b", read(0, 95)),
|
||||
{Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0},
|
||||
{"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0},
|
||||
} {
|
||||
r := mm.m.RuleFor(c.licences[0], c.licences, c.bindings, Defaults)
|
||||
to, window, n := "", "", 0
|
||||
if r.Switch != nil {
|
||||
to, window, n = r.Switch.To, r.Switch.Window, len(r.Switch.Consumers)
|
||||
}
|
||||
if to != c.to || window != c.window || n != c.consumers || r.NoTarget != c.noTarget {
|
||||
t.Errorf("%s: to %q window %q consumers %d no target %v", c.name, to, window, n, r.NoTarget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fourOnA is four consumers bound to a, at 99 % of its weekly window, b with 70 % room and c with 60 %.
|
||||
func fourOnA(t *testing.T) *miniMesh {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 30)), sub("c", read(40, 40))} {
|
||||
_ = mm.store.SaveLicence(ctx, l)
|
||||
}
|
||||
for _, c := range []string{"ace", "g14", "laptop", "server"} {
|
||||
_, _ = mm.store.Bind(ctx, c, "a", Move{Kind: "subscription", Why: "set", By: "test"})
|
||||
}
|
||||
mm.events = nil
|
||||
return mm
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §27/AC1, §26, §21: one act says one licence.switched with the consumers moved, the window,
|
||||
// its percentage, reset and the target's room; one binding.changed per consumer; one audit entry switched with the same
|
||||
// fields; each binding moved by usage, its why naming the licence, the window, the percentage and the reset.
|
||||
func TestOneSwitchedPerAct(t *testing.T) {
|
||||
mm := fourOnA(t)
|
||||
acts, err := mm.m.ApplyRule(context.Background(), "", false, false)
|
||||
if err != nil || len(acts) != 1 || acts[0].To != "b" || acts[0].Room != 70 {
|
||||
t.Fatalf("acts %+v %v", acts, err)
|
||||
}
|
||||
var switched []map[string]any
|
||||
changed := 0
|
||||
for _, e := range mm.events {
|
||||
name, body, _ := strings.Cut(e, " ")
|
||||
var f map[string]any
|
||||
_ = json.Unmarshal([]byte(body), &f)
|
||||
switch name {
|
||||
case "licence.switched":
|
||||
switched = append(switched, f)
|
||||
case "binding.changed." + fmt.Sprint(f["consumer"]):
|
||||
changed++
|
||||
}
|
||||
}
|
||||
if len(switched) != 1 || changed != 4 {
|
||||
t.Fatalf("said %v", mm.events)
|
||||
}
|
||||
s := switched[0]
|
||||
if s["from"] != "a" || s["to"] != "b" || len(s["consumers"].([]any)) != 4 || s["by"] != ByUsage ||
|
||||
s["window"] != "weekly" || s["pct"] != 99.0 || s["resetsAt"] != "2026-11-14T09:00:00Z" || s["room"] != 70.0 {
|
||||
t.Fatalf("licence.switched %v", s)
|
||||
}
|
||||
audit := mm.audit.under("switched")
|
||||
if len(audit) != 1 || audit[0]["window"] != "weekly" || len(audit[0]["consumers"].([]any)) != 4 {
|
||||
t.Fatalf("audit %v", audit)
|
||||
}
|
||||
b := mm.bound("laptop")
|
||||
if b.Licence != "b" || b.By != ByUsage || b.Why != "a reached 99 % of its weekly window, which resets at 2026-11-14T09:00:00Z" {
|
||||
t.Fatalf("laptop %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §28/AC1: the rule tells the operator once per act, and the switch verb never.
|
||||
func TestTheRuleTellsOnceAndTheVerbNever(t *testing.T) {
|
||||
mm := fourOnA(t)
|
||||
var told []string
|
||||
mm.m.Tell = func(_ context.Context, headline string, lines []string) error {
|
||||
told = append(told, headline+" | "+strings.Join(lines, " "))
|
||||
return nil
|
||||
}
|
||||
_, _ = mm.m.ApplyRule(context.Background(), "", false, false)
|
||||
if len(told) != 1 || told[0] != "ace, g14, laptop, server moved to b | a reached 99 % of its weekly window, "+
|
||||
"which resets at 2026-11-14T09:00:00Z; b has 70 % room" {
|
||||
t.Fatalf("told %v", told)
|
||||
}
|
||||
_, _ = mm.m.Bind(WithCause(context.Background(), Cause{Why: "the switch verb", By: "a person"}), "ace", "c")
|
||||
if len(told) != 1 {
|
||||
t.Fatalf("the switch verb told %v", told)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §32/AC1, §33/AC2: dry answers the same moves as the act and moves nothing; an invalid pair
|
||||
// of settings, or switch_auto off, moves nothing after a reading, and the rule by hand runs with switch_auto off.
|
||||
func TestDryAnswersTheSameMovesAndMovesNothing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mm := fourOnA(t)
|
||||
dry, err := mm.m.ApplyRule(ctx, "a", true, true)
|
||||
if err != nil || len(dry) != 1 || mm.bound("ace").Licence != "a" || len(mm.events) != 0 {
|
||||
t.Fatalf("dry %+v %v, said %v", dry, err, mm.events)
|
||||
}
|
||||
acts, _ := mm.m.ApplyRule(ctx, "a", true, false)
|
||||
if len(acts) != 1 || acts[0].To != dry[0].To || strings.Join(acts[0].Consumers, ",") != strings.Join(dry[0].Consumers, ",") {
|
||||
t.Fatalf("dry %+v, act %+v", dry, acts)
|
||||
}
|
||||
for name, change := range map[string]func(*Settings){
|
||||
"an invalid pair": func(s *Settings) { s.RoomBelow = 100 },
|
||||
"switch_auto off": func(s *Settings) { s.SwitchAuto = false },
|
||||
} {
|
||||
mm := fourOnA(t)
|
||||
change(&mm.m.Settings)
|
||||
if acts, _ := mm.m.ApplyRule(ctx, "", false, false); len(acts) != 0 || mm.bound("ace").Licence != "a" {
|
||||
t.Errorf("%s moved %+v", name, acts)
|
||||
}
|
||||
}
|
||||
mm = fourOnA(t)
|
||||
mm.m.Settings.SwitchAuto = false
|
||||
if acts, _ := mm.m.ApplyRule(ctx, "", true, false); len(acts) != 1 || mm.bound("ace").Licence != "b" {
|
||||
t.Fatalf("by hand with switch_auto off moved %+v", acts)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §26: a reading at the threshold applies the rule at once.
|
||||
func TestAReadingAtTheThresholdApplies(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
|
||||
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
|
||||
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
|
||||
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b := mm.bound("laptop"); b.Licence != "b@example.org" || b.By != ByUsage {
|
||||
t.Fatalf("laptop %+v", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription","refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":4,"changedAt":"2026-10-04T11:00:00.000Z"}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
// The module's acting tools (design 39 bus-and-switch §38, §39, §42; design 39 parts §4): registered by the manager
|
||||
// bundle beside the seat's verbs, they start by hand what the manager does by itself. The tools that only read are the
|
||||
// tools bundle's (cmd/tools).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// tool is one of the module's own tools.
|
||||
func tool(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: name, Description: description, Input: input,
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
m, err := manager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " tool", By: stdio.CallerOf(a)})
|
||||
return run(ctx, m, a)
|
||||
}}
|
||||
}
|
||||
|
||||
func boolean(description string) map[string]any {
|
||||
return map[string]any{"type": "boolean", "description": description}
|
||||
}
|
||||
|
||||
func number(description string) map[string]any {
|
||||
return map[string]any{"type": "number", "description": description}
|
||||
}
|
||||
|
||||
func moduleTools() []stdio.Tool {
|
||||
licence := str("a licence, as `licences` names it; absent for all")
|
||||
dry := boolean("answer what the rule would do, and do nothing")
|
||||
return []stdio.Tool{
|
||||
tool("licences_read_usage", "Read usage now, for one subscription or every one, as the manager does on its cadence; then apply the switch rule unless dry. Answers each reading and what the rule did or would do.",
|
||||
map[string]any{"licence": licence, "dry": dry},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
l, _ := a["licence"].(string)
|
||||
d, _ := a["dry"].(bool)
|
||||
return m.ReadUsageOf(ctx, strings.TrimSpace(l), d)
|
||||
}),
|
||||
tool("licences_switch_now", "Apply the switch rule now from the latest readings, for one licence or all, even with switch_auto off since a person asked; with dry, answer the moves and make none.",
|
||||
map[string]any{"licence": licence, "dry": dry},
|
||||
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
|
||||
l, _ := a["licence"].(string)
|
||||
d, _ := a["dry"].(bool)
|
||||
acts, err := m.ApplyRule(ctx, strings.TrimSpace(l), true, d)
|
||||
if acts == nil {
|
||||
acts = []Switch{}
|
||||
}
|
||||
return acts, err
|
||||
}),
|
||||
tool("licences_resync", "Recover: say binding.changed again for every binding, ask every node what it holds, say again each condition held, and hand each licence's access token to the vault again. Answers what it said.",
|
||||
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) {
|
||||
return m.Resync(ctx)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Resync says again what a consumer, the vault or a node may have missed (§42): binding.changed for every binding,
|
||||
// holdings.asked, each condition held, and licence-put of each licence's access token. Answers what it said.
|
||||
func (m *Manager) Resync(ctx context.Context) (map[string]any, error) {
|
||||
bindings, err := m.Store.Bindings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
said := map[string]any{}
|
||||
consumers := []string{}
|
||||
for _, b := range bindings {
|
||||
m.changed(b, b.Licence)
|
||||
consumers = append(consumers, b.Consumer)
|
||||
}
|
||||
said["binding.changed"] = consumers
|
||||
if err := m.AskHoldings(CauseOf(ctx).By); err != nil {
|
||||
return said, fmt.Errorf("holdings.asked could not be said: %w", err)
|
||||
}
|
||||
said["holdings.asked"] = true
|
||||
if err := m.SayConditions(ctx, true); err != nil {
|
||||
return said, err
|
||||
}
|
||||
keys, err := m.Conditions.Keys()
|
||||
if err != nil {
|
||||
return said, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
said["conditions"] = keys
|
||||
licences, err := m.Store.Licences(ctx)
|
||||
if err != nil {
|
||||
return said, err
|
||||
}
|
||||
put := []string{}
|
||||
var errs []error
|
||||
for _, l := range licences {
|
||||
if l.Generation == 0 {
|
||||
continue
|
||||
}
|
||||
key := KeyFor(l.Name)
|
||||
// The access token is built from the grant, which the vault hands the manager; it hands a licence's access
|
||||
// token only to a machine a binding names (§17), and the manager's machine may have none.
|
||||
access, err := m.Grants.Get(ctx, key, PartGrant)
|
||||
if err == nil && l.Kind == "subscription" {
|
||||
var g FullGrant
|
||||
if err = json.Unmarshal([]byte(access), &g); err == nil {
|
||||
access = accessOf(g)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = m.Grants.Put(ctx, key, PartAccess, l.Generation, access)
|
||||
}
|
||||
access = ""
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("the access token of %s: %w", l.Name, err))
|
||||
continue
|
||||
}
|
||||
put = append(put, l.Name)
|
||||
}
|
||||
said["licence-put"] = put
|
||||
return said, errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// stdTool is one served verb or tool's run.
|
||||
type stdTool func(map[string]any) (any, error)
|
||||
|
||||
// served is every verb and tool the binary serves, by name, run on mm's manager.
|
||||
func served(t *testing.T, mm *miniMesh) map[string]stdTool {
|
||||
t.Helper()
|
||||
built = mm.m
|
||||
t.Cleanup(func() { built = nil })
|
||||
out := map[string]stdTool{}
|
||||
for _, tool := range tools() {
|
||||
out[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// answer is a tool's answer as JSON, decoded into a generic form.
|
||||
func answer(t *testing.T, run stdTool, args map[string]any) any {
|
||||
t.Helper()
|
||||
got, err := run(args)
|
||||
if err != nil {
|
||||
t.Fatalf("%v answered %v", args, err)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
var v any
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
return v
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §34/AC1, §47, B40: an answer per licence carries today's fields, its organisation, usage,
|
||||
// room or why it is not known, whether it is the next target, when and from where it was adopted and its open
|
||||
// conditions, and no token.
|
||||
func TestLicencesAnswersEachFieldAndNoToken(t *testing.T) {
|
||||
mm := fourOnA(t)
|
||||
ctx := context.Background()
|
||||
_ = mm.store.SaveLicence(ctx, Licence{Name: "k", Kind: "api-key", Generation: 1, AdoptedAt: t0.UnixMilli(),
|
||||
AdoptedFrom: "a file"})
|
||||
_, _ = saying(mm)
|
||||
_ = mm.store.SaveLicence(ctx, sub("a", read(40, 95)))
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
got := answer(t, served(t, mm)["licences"], nil).([]any)
|
||||
by := map[string]map[string]any{}
|
||||
for _, l := range got {
|
||||
f := l.(map[string]any)
|
||||
by[f["name"].(string)] = f
|
||||
for _, k := range []string{"name", "kind", "account", "organisation", "accessExpiresAt", "refreshExpiresAt",
|
||||
"failures", "bound", "usage", "room", "target", "adoptedAt", "adoptedFrom", "conditions"} {
|
||||
if _, ok := f[k]; !ok {
|
||||
t.Errorf("%s has no %s: %v", f["name"], k, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
if by["b"]["target"] != true || by["c"]["target"] != false || by["a"]["room"] != 5.0 ||
|
||||
by["k"]["room"] != "is an API key, whose room is never known" || by["k"]["adoptedFrom"] != "a file" ||
|
||||
len(by["a"]["bound"].([]any)) != 4 || by["a"]["usage"].(map[string]any)["weeklyResetsAt"] != "2026-11-14T09:00:00Z" {
|
||||
t.Fatalf("licences %v", by)
|
||||
}
|
||||
if cs := by["a"]["conditions"].([]any); len(cs) != 1 || cs[0].(map[string]any)["key"] != "licence.a.near-limit" {
|
||||
t.Fatalf("a's conditions %v", cs)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
for _, token := range []string{"rt-", "at-", "local-"} {
|
||||
if strings.Contains(string(raw), token) {
|
||||
t.Fatalf("licences holds %s: %s", token, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §35/AC1: since and limit bound the answer, newest first; raw adds the vendor's answer.
|
||||
func TestUsageIsBoundedBySinceAndLimit(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_ = mm.store.SaveLicence(ctx, sub("a", nil))
|
||||
for i := range 30 {
|
||||
_ = mm.store.RecordUsage(ctx, "a", t0.Add(time.Duration(i)*time.Minute).UnixMilli(), UsageReading{},
|
||||
map[string]any{"i": float64(i)})
|
||||
}
|
||||
run := served(t, mm)["usage"]
|
||||
rows := answer(t, run, map[string]any{"licence": "a"}).([]any)
|
||||
first := rows[0].(map[string]any)
|
||||
if len(rows) != 20 || first["at"] != float64(t0.Add(29*time.Minute).UnixMilli()) || first["raw"] != nil {
|
||||
t.Fatalf("by default %d rows, the first %v", len(rows), first)
|
||||
}
|
||||
since := t0.Add(25 * time.Minute).UTC().Format(time.RFC3339)
|
||||
rows = answer(t, run, map[string]any{"since": since, "limit": 3.0, "raw": true}).([]any)
|
||||
if len(rows) != 3 || rows[0].(map[string]any)["raw"].(map[string]any)["i"] != 29.0 {
|
||||
t.Fatalf("since and limit 3: %v", rows)
|
||||
}
|
||||
if rows = answer(t, run, map[string]any{"since": since, "limit": 5000.0}).([]any); len(rows) != 5 {
|
||||
t.Fatalf("since: %d rows", len(rows))
|
||||
}
|
||||
if _, err := run(map[string]any{"since": "yesterday"}); err == nil {
|
||||
t.Fatal("a since that is not a time is answered")
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §38/AC1, §24, §32: reading usage now writes the reading and applies the rule, and with dry
|
||||
// reads, answers the moves and moves nothing.
|
||||
func TestReadUsageNowAppliesTheRuleUnlessDry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, dry := range []bool{true, false} {
|
||||
mm := newMesh(t)
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
_ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)})
|
||||
mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0},
|
||||
"seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}}
|
||||
got := answer(t, served(t, mm)["licences_read_usage"], map[string]any{"dry": dry}).([]any)
|
||||
r := got[0].(map[string]any)
|
||||
moved := mm.bound("laptop").Licence == "b@example.org"
|
||||
if len(got) != 1 || r["reading"].(map[string]any)["weeklyPct"] != 99.0 ||
|
||||
len(r["switches"].([]any)) != 1 || moved == dry || len(mm.usage.under("")) != 1 {
|
||||
t.Fatalf("dry %v: answered %v, moved %v", dry, got, moved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §39/AC1, §32: switching now runs the rule with switch_auto off, and dry moves nothing; an
|
||||
// acting tool waits for nothing to be carried over (design 39 parts §11).
|
||||
func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) {
|
||||
mm := fourOnA(t)
|
||||
mm.m.Settings.SwitchAuto = false
|
||||
run := served(t, mm)["licences_switch_now"]
|
||||
if got := answer(t, run, map[string]any{"dry": true}).([]any); len(got) != 1 || mm.bound("ace").Licence != "a" {
|
||||
t.Fatalf("dry %v", got)
|
||||
}
|
||||
if got := answer(t, run, map[string]any{"licence": "a"}).([]any); len(got) != 1 || mm.bound("ace").Licence != "b" {
|
||||
t.Fatalf("switched %v, ace on %s", got, mm.bound("ace").Licence)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §42/AC1: resync says binding.changed once per binding, holdings.asked once, each condition
|
||||
// once, and licence-put of each licence's access once.
|
||||
func TestResyncSaysEachOnce(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, said := saying(mm)
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.login("server", "rt-b", true, t0)})
|
||||
mm.m.Settings.WarnAt = 100
|
||||
_ = mm.m.SayConditions(ctx, false)
|
||||
mm.events, *said = nil, nil
|
||||
mm.grants.refuseAccess = true // §42: the access token is built from the grant, never read back from the vault
|
||||
got := answer(t, served(t, mm)["licences_resync"], nil).(map[string]any)
|
||||
count := map[string]int{}
|
||||
for _, e := range mm.events {
|
||||
name, _, _ := strings.Cut(e, " ")
|
||||
if strings.HasPrefix(name, BindingChanged+".") {
|
||||
name = BindingChanged
|
||||
}
|
||||
count[name]++
|
||||
}
|
||||
puts, _ := got["licence-put"].([]any)
|
||||
if count["binding.changed"] != 2 || count[HoldingsAsked] != 1 || len(*said) != 1 || len(puts) != 1 || puts[0] != licence1 {
|
||||
t.Fatalf("said %v, conditions %d, answered %v", mm.events, len(*said), got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
// The vault holds every licence's grant and access token (design 39 bus-and-switch §15, §16, B4, B5): the manager hands
|
||||
// what it gets to `licence-put` in two steps, with a key the vault made for the call, and reads a grant with `get`,
|
||||
// sealed to its own public key, for one act.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
// The parts of a licence the vault holds (§15).
|
||||
const (
|
||||
PartGrant = "grant"
|
||||
PartAccess = "access"
|
||||
)
|
||||
|
||||
// Grants is where a licence's two secrets are kept.
|
||||
type Grants interface {
|
||||
// Put hands one part of a licence, by its key, to the vault at a generation; an older one is refused.
|
||||
Put(ctx context.Context, key, part string, generation int64, value string) error
|
||||
// Get reads one part of a licence from the vault.
|
||||
Get(ctx context.Context, key, part string) (string, error)
|
||||
}
|
||||
|
||||
// SecretName is the name of one part of a licence in the vault.
|
||||
func SecretName(key, part string) string { return "licence." + key + "." + part }
|
||||
|
||||
// VaultGrants is the vault on the bus, asked as the seat mesh-vault.
|
||||
type VaultGrants struct{ Keys KeyPair }
|
||||
|
||||
// Put asks licence-put's two steps.
|
||||
func (v VaultGrants) Put(_ context.Context, key, part string, generation int64, value string) error {
|
||||
raw, err := stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"licence": key, "part": part, "generation": generation})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var opened struct {
|
||||
Call string `json:"call"`
|
||||
SealTo string `json:"seal_to"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &opened); err != nil {
|
||||
return err
|
||||
}
|
||||
pub, err := base64.StdEncoding.DecodeString(opened.SealTo)
|
||||
if err != nil || len(pub) != 32 || opened.Call == "" {
|
||||
return fmt.Errorf("licence-put answered no call and key to seal to: %s", raw)
|
||||
}
|
||||
var to [32]byte
|
||||
copy(to[:], pub)
|
||||
sealed, err := box.SealAnonymous(nil, []byte(value), &to, rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"call": opened.Call,
|
||||
"sealed": base64.StdEncoding.EncodeToString(sealed)})
|
||||
return err
|
||||
}
|
||||
|
||||
// Get asks get, sealed to the manager's public key, and opens the answer.
|
||||
func (v VaultGrants) Get(_ context.Context, key, part string) (string, error) {
|
||||
raw, err := stdio.Ask("seat:mesh-vault.get", map[string]any{"name": SecretName(key, part), "public_key": v.Keys.PublicKey})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var a struct {
|
||||
Sealed SealedBox `json:"sealed"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return Open(a.Sealed, v.Keys.PrivateKey)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// design 39 bus-and-switch §15, §16, §20: a rotation reads the grant from the vault and hands it back, the grant first
|
||||
// and then the access, at the next generation; the licence keeps no token once the call returns.
|
||||
func TestARotationPutsTwiceAndKeepsNoToken(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
if _, err := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := KeyFor(licence1)
|
||||
mm.grants.puts = nil
|
||||
if r, err := mm.m.Rotate(ctx, licence1, true); err != nil || r["refreshed"] != true {
|
||||
t.Fatalf("%v %v", r, err)
|
||||
}
|
||||
want := []string{SecretName(key, PartGrant) + "@2", SecretName(key, PartAccess) + "@2"}
|
||||
if strings.Join(mm.grants.puts, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("the rotation put %v, want %v", mm.grants.puts, want)
|
||||
}
|
||||
if g, _ := mm.grants.Get(ctx, key, PartGrant); !strings.Contains(g, `"refreshToken":"rt-2"`) {
|
||||
t.Fatalf("the vault holds the grant %s", g)
|
||||
}
|
||||
if a, _ := mm.grants.Get(ctx, key, PartAccess); !strings.Contains(a, `"accessToken":"at-2"`) || strings.Contains(a, "refreshToken\":\"rt") {
|
||||
t.Fatalf("the vault holds the access %s", a)
|
||||
}
|
||||
l, _ := mm.store.Licence(ctx, licence1)
|
||||
raw, _ := json.Marshal(l)
|
||||
for _, token := range []string{"rt-", "at-"} {
|
||||
if strings.Contains(string(raw), token) {
|
||||
t.Fatalf("the licence keeps %s: %s", token, raw)
|
||||
}
|
||||
}
|
||||
if l.Generation != 2 {
|
||||
t.Fatalf("the licence's generation is %d, want 2", l.Generation)
|
||||
}
|
||||
}
|
||||
+21
-16
@@ -14,6 +14,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// The public Claude Code client's id: not a secret, and a hard-won constant — a metadata URL in its place
|
||||
@@ -140,6 +142,8 @@ func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed {
|
||||
return NextGrant(g, r, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
// Usage is the vendor's usage answer whole; a failed answer is an error naming its status and reason, the body, never
|
||||
// only the status (design 39 bus-and-switch §24).
|
||||
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
|
||||
if err != nil {
|
||||
@@ -148,25 +152,25 @@ func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]a
|
||||
req.Header.Set("authorization", "Bearer "+accessToken)
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("the usage endpoint did not answer: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode)
|
||||
reason := string(body)
|
||||
if len(reason) > 400 {
|
||||
reason = reason[:400]
|
||||
}
|
||||
return nil, fmt.Errorf("the usage endpoint answered %d: %s", resp.StatusCode, reason)
|
||||
}
|
||||
var out map[string]any
|
||||
return out, json.NewDecoder(resp.Body).Decode(&out)
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("the usage endpoint answered %d with what is not JSON", resp.StatusCode)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows.
|
||||
type UsageReading struct {
|
||||
SessionPct *float64 `json:"sessionPct"`
|
||||
SessionResetsAt string `json:"sessionResetsAt,omitempty"`
|
||||
WeeklyPct *float64 `json:"weeklyPct"`
|
||||
SonnetPct *float64 `json:"sonnetPct"`
|
||||
}
|
||||
|
||||
// FlattenUsage reads the windows the predecessor read.
|
||||
// FlattenUsage reads the session, weekly and sonnet windows, each with its reset time (§24).
|
||||
func FlattenUsage(u map[string]any) UsageReading {
|
||||
window := func(k string) (*float64, string) {
|
||||
w, ok := u[k].(map[string]any)
|
||||
@@ -180,8 +184,9 @@ func FlattenUsage(u map[string]any) UsageReading {
|
||||
}
|
||||
return &pct, resets
|
||||
}
|
||||
s, resets := window("five_hour")
|
||||
w, _ := window("seven_day")
|
||||
so, _ := window("seven_day_sonnet")
|
||||
return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so}
|
||||
s, sResets := window("five_hour")
|
||||
w, wResets := window("seven_day")
|
||||
so, soResets := window("seven_day_sonnet")
|
||||
return UsageReading{SessionPct: s, SessionResetsAt: sResets, WeeklyPct: w, WeeklyResetsAt: wResets, SonnetPct: so,
|
||||
SonnetResetsAt: soResets}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
// claude-licence-manager-tools (design 39 parts §3): the licence manager's tools bundle, loaded by the module's runtime
|
||||
// of its own beside the manager bundle. It answers the module's four reading tools and writes nothing: it reads the
|
||||
// module's kept data with the runtime's bus-state calls and its two logs with the runtime's log calls, each answered
|
||||
// as the module whose bundle asks, and asks mesh-config where each setting was set. The runtime serves the tools of
|
||||
// every bundle of a module that loaded, so these answer while the manager bundle fails to load or stops.
|
||||
//
|
||||
// stdout is the MCP channel; everything this bundle says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", fromTheRuntime().Tools()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[claude-licence-manager-tools] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// fromTheRuntime is the reader of the module's kept data through its runtime, and of its settings file by the word
|
||||
// the manager bundle gives the module (MESH_LICENCE_SETTINGS; ADR 0192: the runtime hands a module's words to all
|
||||
// its bundles).
|
||||
func fromTheRuntime() *Reader {
|
||||
node, _ := os.Hostname()
|
||||
if n := os.Getenv("MESH_NODE"); n != "" {
|
||||
node = n
|
||||
}
|
||||
r := &Reader{States: map[string]licences.State{}, Logs: map[string]licences.Log{},
|
||||
SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"),
|
||||
ConfigLevels: func(context.Context) (map[string]string, error) { return configLevels(node) }}
|
||||
for _, name := range licences.States {
|
||||
r.States[name] = stdio.State(name)
|
||||
}
|
||||
for _, name := range licences.Logs {
|
||||
r.Logs[name] = stdio.Log(name)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// configLevels asks mesh-config where each of the module's own settings was set on its node (bus-and-switch §40,
|
||||
// B18): the source of each resolved value, by its key.
|
||||
func configLevels(node string) (map[string]string, error) {
|
||||
raw, err := stdio.Ask("seat:mesh-config.config", map[string]any{"action": "list", "module": licences.Publisher,
|
||||
"node": node})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var answer struct {
|
||||
Resolved []struct {
|
||||
Key string `json:"key"`
|
||||
Source string `json:"source"`
|
||||
} `json:"resolved"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return nil, fmt.Errorf("mesh-config's answer: %w", err)
|
||||
}
|
||||
levels := map[string]string{}
|
||||
for _, r := range answer.Resolved {
|
||||
levels[r.Key] = r.Source
|
||||
}
|
||||
return levels, nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
// The module's four reading tools (design 39 parts §3; bus-and-switch §37, §40, §41, §43): they read everything the
|
||||
// manager keeps and answer it, and write nothing. Every act is the manager bundle's (parts §4).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// Reader reads the module's kept data and its settings, as the module whose bundle asks.
|
||||
type Reader struct {
|
||||
// States and Logs are the module's kept states and logs, by their names in the manifest.
|
||||
States map[string]licences.State
|
||||
Logs map[string]licences.Log
|
||||
// SettingsFile is the settings file the node-engine writes, read again at every call (bus-and-switch §33).
|
||||
SettingsFile string
|
||||
// ConfigLevels is where mesh-config says each setting was set on the manager's node (mesh, node or default), by
|
||||
// setting (§40, B18).
|
||||
ConfigLevels func(ctx context.Context) (map[string]string, error)
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func number(description string) map[string]any {
|
||||
return map[string]any{"type": "number", "description": description}
|
||||
}
|
||||
|
||||
// sinceOf is the argument since, RFC 3339; absent is the zero time.
|
||||
func sinceOf(a map[string]any) (time.Time, error) {
|
||||
v, _ := a["since"].(string)
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("since is not a time in RFC 3339: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// limitOf is the argument limit: def when absent or not positive, at most most.
|
||||
func limitOf(a map[string]any, def, most int) int {
|
||||
limit := def
|
||||
if v, ok := a["limit"].(float64); ok && v > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
return min(limit, most)
|
||||
}
|
||||
|
||||
// Tools are the four reading tools, registered under the module (parts §3).
|
||||
func (r *Reader) Tools() []stdio.Tool {
|
||||
licence := str("a licence, as `licences` names it; absent for all")
|
||||
return []stdio.Tool{
|
||||
{Name: "licences_switches", Description: "Every switch of a consumer and why, newest first, from the audit: when, from and to which licence, the consumers, by whom and why, and for a switch by usage the reading that moved them.",
|
||||
Input: map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"),
|
||||
"since": str("a time, RFC 3339: no switch before it"), "limit": number("how many (default 20)")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
l, _ := a["licence"].(string)
|
||||
c, _ := a["consumer"].(string)
|
||||
since, err := sinceOf(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Switches(strings.TrimSpace(l), strings.TrimSpace(c), since, limitOf(a, 20, 2000))
|
||||
}},
|
||||
{Name: "licences_settings", Description: "The settings in force: each one's value, its default, where it was set (mesh, node or the default, as mesh-config answers it) and why it is invalid when it is.",
|
||||
Run: func(map[string]any) (any, error) { return r.SettingsInForce(context.Background()) }},
|
||||
{Name: "licences_kept", Description: "Every entry the manager keeps on the bus: a state's keys, or a log's subjects with their last entry; with key, that key's value or that subject's last entry; and how full each is against its cap.",
|
||||
Input: map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"),
|
||||
"key": str("a key of a state, or a subject of a log")},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
e, _ := a["entry"].(string)
|
||||
k, _ := a["key"].(string)
|
||||
return r.Kept(strings.TrimSpace(e), strings.TrimSpace(k))
|
||||
}},
|
||||
{Name: "licences_conditions", Description: "The conditions the manager said and holds: each key of its state conditions with its body and since.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
return licences.KeptConditions(r.States[licences.EntryConditions])
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// carriedForm gives an entry carried over from the old table `audit` (§12) the fields of §37: the old manager wrote a
|
||||
// switch as `consumer` (one), `licence` (the one moved to), `from` and `by`, and no `consumers` or `to`.
|
||||
func carriedForm(e map[string]any) {
|
||||
if _, ok := e["to"]; !ok {
|
||||
if l, ok := e["licence"]; ok {
|
||||
e["to"] = l
|
||||
}
|
||||
}
|
||||
if _, ok := e["consumers"]; !ok {
|
||||
if c, ok := e["consumer"]; ok {
|
||||
e["consumers"] = []any{c}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Switches are the audit's switches, newest first: of a licence (from or to it) and a consumer when named, none before
|
||||
// since, at most limit (§37).
|
||||
func (r *Reader) Switches(licence, consumer string, since time.Time, limit int) ([]map[string]any, error) {
|
||||
audit, ok := r.Logs[licences.EntryAudit]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the module keeps no log %s", licences.EntryAudit)
|
||||
}
|
||||
info, err := audit.Info()
|
||||
if err != nil || info.Last == 0 {
|
||||
return []map[string]any{}, err
|
||||
}
|
||||
var all []stdio.LogEntry
|
||||
for next := uint64(1); next != 0; {
|
||||
page, err := audit.Read(next, "switched", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page.Entries...)
|
||||
next = page.Next
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
var e map[string]any
|
||||
if json.Unmarshal(all[i].Value, &e) != nil {
|
||||
continue
|
||||
}
|
||||
carriedForm(e)
|
||||
at, _ := time.Parse(time.RFC3339Nano, fmt.Sprint(e["at"]))
|
||||
if at.Before(since) {
|
||||
continue
|
||||
}
|
||||
if licence != "" && e["from"] != licence && e["to"] != licence {
|
||||
continue
|
||||
}
|
||||
consumers, _ := e["consumers"].([]any)
|
||||
if consumer != "" && !slices.Contains(consumers, any(consumer)) {
|
||||
continue
|
||||
}
|
||||
s := map[string]any{"at": e["at"], "from": e["from"], "to": e["to"], "consumers": e["consumers"],
|
||||
"by": e["by"], "why": e["why"]}
|
||||
if _, ok := e["window"]; ok {
|
||||
s["reading"] = map[string]any{"window": e["window"], "pct": e["pct"], "resetsAt": e["resetsAt"],
|
||||
"room": e["room"]}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SettingAnswer is one setting as licences_settings answers it (§40).
|
||||
type SettingAnswer struct {
|
||||
Setting string `json:"setting"`
|
||||
Value any `json:"value"`
|
||||
Default any `json:"default"`
|
||||
Level string `json:"level"`
|
||||
Invalid string `json:"invalid,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsInForce is each setting in force, read from the settings file, its default, where mesh-config says it was
|
||||
// set, and why it is invalid when it is (§40, B18).
|
||||
func (r *Reader) SettingsInForce(ctx context.Context) ([]SettingAnswer, error) {
|
||||
levels := map[string]string{}
|
||||
if r.ConfigLevels != nil {
|
||||
got, err := r.ConfigLevels(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mesh-config did not say where each setting was set: %w", err)
|
||||
}
|
||||
levels = got
|
||||
}
|
||||
s := licences.SettingsFrom(r.SettingsFile)
|
||||
reasons := s.Reasons()
|
||||
values, defaults := s.Values(), licences.Defaults.Values()
|
||||
var out []SettingAnswer
|
||||
for k, v := range values {
|
||||
a := SettingAnswer{Setting: k, Value: v, Default: defaults[k], Level: levels[k], Invalid: reasons[k]}
|
||||
if a.Level == "" {
|
||||
a.Level = "default"
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Setting < out[j].Setting })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Kept answers one entry the module keeps, or every one (§41): a state's keys and their count, a log's subjects with
|
||||
// their last entry and its fill against its cap; with key, one key's value or one subject's last entry.
|
||||
func (r *Reader) Kept(entry, key string) (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
names := []string{}
|
||||
for n := range r.States {
|
||||
names = append(names, n)
|
||||
}
|
||||
for n := range r.Logs {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if entry != "" && !slices.Contains(names, entry) {
|
||||
return nil, fmt.Errorf("the manager keeps no entry %q: it keeps %s", entry, strings.Join(names, ", "))
|
||||
}
|
||||
for _, n := range names {
|
||||
if entry != "" && n != entry {
|
||||
continue
|
||||
}
|
||||
if st, ok := r.States[n]; ok {
|
||||
a, err := stateAnswer(st, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", n, err)
|
||||
}
|
||||
out[n] = a
|
||||
continue
|
||||
}
|
||||
a, err := logAnswer(r.Logs[n], key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", n, err)
|
||||
}
|
||||
out[n] = a
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func stateAnswer(st licences.State, key string) (map[string]any, error) {
|
||||
keys, err := st.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
a := map[string]any{"kind": "state", "keys": keys, "fill": map[string]any{"keys": len(keys), "cap": "none declared"}}
|
||||
if key != "" {
|
||||
e, err := st.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e == nil {
|
||||
a["value"] = nil
|
||||
} else {
|
||||
a["value"] = e.Value
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func logAnswer(l licences.Log, key string) (map[string]any, error) {
|
||||
info, err := l.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := map[string]any{"kind": "log", "fill": map[string]any{"entries": info.Entries, "bytes": info.Bytes,
|
||||
"maxBytes": info.MaxBytes}}
|
||||
if key != "" {
|
||||
e, err := l.Last(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a["last"] = e
|
||||
return a, nil
|
||||
}
|
||||
newest, err := l.Newest("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subjects := map[string]any{}
|
||||
for _, e := range newest {
|
||||
subjects[e.Key] = e
|
||||
}
|
||||
a["subjects"] = subjects
|
||||
return a, nil
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"claude-licence-manager/internal/licences"
|
||||
)
|
||||
|
||||
// runtime is a fixture of the runtime's bus-state and log calls (design 39 parts §3): it answers every read from what
|
||||
// a test seeds, and fails the test on any write or append the tools bundle makes.
|
||||
type runtime struct {
|
||||
t *testing.T
|
||||
states map[string]map[string]json.RawMessage
|
||||
logs map[string][]stdio.LogEntry
|
||||
}
|
||||
|
||||
func newRuntime(t *testing.T) *runtime {
|
||||
rt := &runtime{t: t, states: map[string]map[string]json.RawMessage{}, logs: map[string][]stdio.LogEntry{}}
|
||||
for _, s := range licences.States {
|
||||
rt.states[s] = map[string]json.RawMessage{}
|
||||
}
|
||||
for _, l := range licences.Logs {
|
||||
rt.logs[l] = nil
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
// seedState and seedLog keep what the manager wrote before the tools were asked.
|
||||
func (rt *runtime) seedState(state, key string, v any) {
|
||||
raw, _ := json.Marshal(v)
|
||||
rt.states[state][key] = raw
|
||||
}
|
||||
|
||||
func (rt *runtime) seedLog(log, key string, v any) {
|
||||
raw, _ := json.Marshal(v)
|
||||
rt.logs[log] = append(rt.logs[log], stdio.LogEntry{Sequence: uint64(len(rt.logs[log]) + 1), Key: key, Value: raw})
|
||||
}
|
||||
|
||||
func (rt *runtime) reader() *Reader {
|
||||
r := &Reader{States: map[string]licences.State{}, Logs: map[string]licences.Log{}}
|
||||
for s := range rt.states {
|
||||
r.States[s] = keptState{rt, s}
|
||||
}
|
||||
for l := range rt.logs {
|
||||
r.Logs[l] = keptLog{rt, l}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
type keptState struct {
|
||||
rt *runtime
|
||||
name string
|
||||
}
|
||||
|
||||
func (s keptState) wrote(how, key string) error {
|
||||
s.rt.t.Errorf("the tools bundle %s %s.%s: it writes nothing", how, s.name, key)
|
||||
return errors.New("refused: the tools bundle writes nothing")
|
||||
}
|
||||
|
||||
func (s keptState) Get(key string) (*stdio.StateEntry, error) {
|
||||
v, ok := s.rt.states[s.name][key]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &stdio.StateEntry{Key: key, Value: v, Revision: 1}, nil
|
||||
}
|
||||
func (s keptState) Put(key string, _ any) (uint64, error) { return 0, s.wrote("put", key) }
|
||||
func (s keptState) PutTTL(key string, _ any, _ time.Duration) (uint64, error) {
|
||||
return 0, s.wrote("put", key)
|
||||
}
|
||||
func (s keptState) Create(key string, _ any) (uint64, error) { return 0, s.wrote("created", key) }
|
||||
func (s keptState) CreateTTL(key string, _ any, _ time.Duration) (uint64, error) {
|
||||
return 0, s.wrote("created", key)
|
||||
}
|
||||
func (s keptState) Delete(key string) error { return s.wrote("deleted", key) }
|
||||
func (s keptState) Keys() ([]string, error) {
|
||||
keys := []string{}
|
||||
for k := range s.rt.states[s.name] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys, nil
|
||||
}
|
||||
func (s keptState) History(key string) ([]stdio.StateRevision, error) { return nil, nil }
|
||||
|
||||
type keptLog struct {
|
||||
rt *runtime
|
||||
name string
|
||||
}
|
||||
|
||||
func (l keptLog) Append(key string, _ any) (uint64, error) {
|
||||
l.rt.t.Errorf("the tools bundle appended to %s.%s: it writes nothing", l.name, key)
|
||||
return 0, errors.New("refused: the tools bundle writes nothing")
|
||||
}
|
||||
|
||||
func (l keptLog) Read(from uint64, keys string, _ int) (*stdio.LogPage, error) {
|
||||
page := &stdio.LogPage{Last: uint64(len(l.rt.logs[l.name]))}
|
||||
for _, e := range l.rt.logs[l.name] {
|
||||
if e.Sequence >= from && (keys == "" || e.Key == keys) {
|
||||
page.Entries = append(page.Entries, e)
|
||||
}
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (l keptLog) Last(key string) (*stdio.LogEntry, error) {
|
||||
var last *stdio.LogEntry
|
||||
for _, e := range l.rt.logs[l.name] {
|
||||
if e.Key == key {
|
||||
last = &e
|
||||
}
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
func (l keptLog) Info() (*stdio.LogInfo, error) {
|
||||
n := uint64(len(l.rt.logs[l.name]))
|
||||
return &stdio.LogInfo{Entries: n, Last: n, First: min(n, 1), MaxBytes: 64 << 20}, nil
|
||||
}
|
||||
|
||||
func (l keptLog) Newest(string) ([]stdio.LogEntry, error) {
|
||||
by := map[string]stdio.LogEntry{}
|
||||
for _, e := range l.rt.logs[l.name] {
|
||||
by[e.Key] = e
|
||||
}
|
||||
out := []stdio.LogEntry{}
|
||||
for _, e := range by {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// run is one tool's answer as JSON, decoded into a generic form.
|
||||
func run(t *testing.T, r *Reader, name string, args map[string]any) any {
|
||||
t.Helper()
|
||||
for _, tool := range r.Tools() {
|
||||
if tool.Name != name {
|
||||
continue
|
||||
}
|
||||
got, err := tool.Run(args)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %v answered %v", name, args, err)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
var v any
|
||||
_ = json.Unmarshal(raw, &v)
|
||||
return v
|
||||
}
|
||||
t.Fatalf("the tools bundle registers no %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// design 39 parts §3, §4: the tools bundle registers exactly the four reading tools; its code emits nothing,
|
||||
// subscribes to no event, asks no heartbeat, calls neither the vendor nor the vault, and asks nothing but mesh-config.
|
||||
func TestTheToolsBundleRegistersTheFourReadingToolsAndActsOnNothing(t *testing.T) {
|
||||
var names []string
|
||||
for _, tool := range newRuntime(t).reader().Tools() {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if want := []string{"licences_conditions", "licences_kept", "licences_settings", "licences_switches"}; !slices.Equal(names, want) {
|
||||
t.Fatalf("the tools bundle registers %v, not %v", names, want)
|
||||
}
|
||||
files, _ := filepath.Glob("*.go")
|
||||
acts := regexp.MustCompile(`stdio\.(Emit|Subscribe|SeatPublish|Publish|Heartbeat|Watch)\b|\.(Put|PutTTL|Create|CreateTTL|Delete|Append)\(|os\.(WriteFile|Create|OpenFile)|http\.`)
|
||||
asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`)
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range acts.FindAllString(string(src), -1) {
|
||||
t.Errorf("%s calls %s: the tools bundle acts on nothing", f, m)
|
||||
}
|
||||
for _, m := range asked.FindAllStringSubmatch(string(src), -1) {
|
||||
if m[1] != "seat:mesh-config.config" {
|
||||
t.Errorf("%s asks %s: the tools bundle asks only mesh-config", f, m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §37/AC1, design 39 parts §3: a switch by usage and one by a person are answered, newest
|
||||
// first, with their causes, a switch by usage with the reading that moved them; licence and consumer narrow the
|
||||
// answer; nothing is written.
|
||||
func TestSwitchesAnswersBothCauses(t *testing.T) {
|
||||
rt := newRuntime(t)
|
||||
rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-12T10:00:00Z", "from": "a", "to": "b",
|
||||
"consumers": []string{"ace", "g14"}, "by": "usage", "why": "a at 99 %", "window": "weekly", "pct": 99,
|
||||
"resetsAt": "2026-11-14T09:00:00Z", "room": 60})
|
||||
rt.seedLog(licences.EntryAudit, "bound", map[string]any{"at": "2026-11-12T10:00:30Z", "consumer": "ace"})
|
||||
rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-12T10:01:00Z", "from": "b", "to": "c",
|
||||
"consumers": []string{"ace"}, "by": "a person", "why": "the switch verb"})
|
||||
r := rt.reader()
|
||||
got := run(t, r, "licences_switches", nil).([]any)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("switches %v", got)
|
||||
}
|
||||
person, usage := got[0].(map[string]any), got[1].(map[string]any)
|
||||
if person["by"] != "a person" || person["why"] != "the switch verb" || person["to"] != "c" || person["reading"] != nil ||
|
||||
usage["by"] != "usage" || usage["reading"].(map[string]any)["window"] != "weekly" || usage["from"] != "a" {
|
||||
t.Fatalf("switches %v", got)
|
||||
}
|
||||
if got := run(t, r, "licences_switches", map[string]any{"licence": "c"}).([]any); len(got) != 1 {
|
||||
t.Fatalf("of c %v", got)
|
||||
}
|
||||
if got := run(t, r, "licences_switches", map[string]any{"consumer": "g14"}).([]any); len(got) != 1 ||
|
||||
got[0].(map[string]any)["by"] != "usage" {
|
||||
t.Fatalf("of g14 %v", got)
|
||||
}
|
||||
if got := run(t, r, "licences_switches", map[string]any{"since": "2026-11-12T10:00:30Z"}).([]any); len(got) != 1 {
|
||||
t.Fatalf("since %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §37, §12: a switch carried over from the old table keeps the old field names (consumer,
|
||||
// licence) and is answered with `to` and `consumers`, and found by them.
|
||||
func TestSwitchesAnswersCarriedEntries(t *testing.T) {
|
||||
rt := newRuntime(t)
|
||||
rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-11T21:10:33Z", "consumer": "n1",
|
||||
"licence": "d@example.org", "from": "c@example.org", "by": "a@example.org"})
|
||||
r := rt.reader()
|
||||
for _, args := range []map[string]any{nil, {"consumer": "n1"}, {"licence": "d@example.org"}} {
|
||||
got := run(t, r, "licences_switches", args).([]any)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("%v: %v", args, got)
|
||||
}
|
||||
e := got[0].(map[string]any)
|
||||
if e["to"] != "d@example.org" || e["from"] != "c@example.org" || e["by"] != "a@example.org" ||
|
||||
len(e["consumers"].([]any)) != 1 || e["consumers"].([]any)[0] != "n1" {
|
||||
t.Fatalf("%v: %v", args, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §40/AC1, B18, design 39 parts §3: a value set at node level is answered with its level, the
|
||||
// rest with the default, each with its value and default, and an invalid one saying why, read from the settings file;
|
||||
// mesh-config not answering is an error, never the default.
|
||||
func TestSettingsAnswersTheLevel(t *testing.T) {
|
||||
r := newRuntime(t).reader()
|
||||
r.SettingsFile = filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(r.SettingsFile, []byte(`{"switch_at_pct": 80, "warn_at_pct": 85, "usage_minutes": 900}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.ConfigLevels = func(context.Context) (map[string]string, error) {
|
||||
return map[string]string{"switch_at_pct": "node", "warn_at_pct": "mesh", "cadence_minutes": "default"}, nil
|
||||
}
|
||||
by := map[string]map[string]any{}
|
||||
for _, s := range run(t, r, "licences_settings", nil).([]any) {
|
||||
f := s.(map[string]any)
|
||||
by[f["setting"].(string)] = f
|
||||
}
|
||||
str := func(v any) string { s, _ := v.(string); return s }
|
||||
if len(by) != len(licences.Defaults.Values()) || by["switch_at_pct"]["level"] != "node" ||
|
||||
by["switch_at_pct"]["value"] != 80.0 || by["switch_at_pct"]["default"] != 99.0 ||
|
||||
by["usage_minutes"]["level"] != "default" || by["usage_minutes"]["value"] != 5.0 ||
|
||||
!strings.Contains(str(by["usage_minutes"]["invalid"]), "outside its bound") ||
|
||||
!strings.Contains(str(by["room_below_pct"]["invalid"]), "above switch_at_pct 80") ||
|
||||
!strings.Contains(str(by["warn_at_pct"]["invalid"]), "85") || by["switch_auto"]["invalid"] != nil {
|
||||
t.Fatalf("settings %v", by)
|
||||
}
|
||||
r.ConfigLevels = func(context.Context) (map[string]string, error) { return nil, errors.New("no responders") }
|
||||
for _, tool := range r.Tools() {
|
||||
if tool.Name == "licences_settings" {
|
||||
if _, err := tool.Run(nil); err == nil {
|
||||
t.Fatal("mesh-config not answering is answered as the default")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §41/AC1, design 39 parts §3, §10: every bus-state entry the manifest asks for is read by
|
||||
// licences_kept, with its keys or subjects, one key's value and its fill, the carry-over's record among them; an
|
||||
// entry not kept is refused; nothing is written.
|
||||
func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Requires []struct {
|
||||
Name string `json:"name"`
|
||||
Template string `json:"template"`
|
||||
} `json:"requires"`
|
||||
Logs []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"logs"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt := newRuntime(t)
|
||||
rt.seedState(licences.EntryLicences, "a_at_example.org", licences.Licence{Name: "a@example.org", Kind: "subscription"})
|
||||
rt.seedState(licences.EntryCarried, "postgres", map[string]any{"at": "2026-11-12T09:44:59Z"})
|
||||
rt.seedLog(licences.EntryUsage, "a_at_example.org", licences.UsageEntry{At: "2026-11-12T10:00:00Z"})
|
||||
r := rt.reader()
|
||||
all := run(t, r, "licences_kept", nil).(map[string]any)
|
||||
kinds := map[string]string{}
|
||||
for _, e := range m.Requires {
|
||||
kinds[e.Name] = e.Template
|
||||
}
|
||||
for _, l := range m.Logs {
|
||||
kinds[l.Name] = "log"
|
||||
}
|
||||
for name, kind := range kinds {
|
||||
got, ok := all[name].(map[string]any)
|
||||
if !ok || got["fill"] == nil || got["kind"] != kind {
|
||||
t.Errorf("licences_kept does not read %s as a %s: %v", name, kind, all[name])
|
||||
}
|
||||
}
|
||||
if len(kinds) != 8 || len(all) != len(kinds) {
|
||||
t.Fatalf("the manifest asks for %d entries, licences_kept reads %d", len(kinds), len(all))
|
||||
}
|
||||
one := run(t, r, "licences_kept", map[string]any{"entry": "carried", "key": "postgres"}).(map[string]any)
|
||||
if v, _ := one["carried"].(map[string]any)["value"].(map[string]any); v["at"] != "2026-11-12T09:44:59Z" {
|
||||
t.Fatalf("the carry-over's record %v", one)
|
||||
}
|
||||
if subjects := all["usage"].(map[string]any)["subjects"].(map[string]any); subjects["a_at_example.org"] == nil {
|
||||
t.Fatalf("usage subjects %v", subjects)
|
||||
}
|
||||
if _, err := r.Kept("tables", ""); err == nil {
|
||||
t.Fatal("an entry not kept is answered")
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §43/AC1, design 39 parts §3: each key of conditions is answered with its body and since.
|
||||
func TestConditionsAnswersEachKey(t *testing.T) {
|
||||
rt := newRuntime(t)
|
||||
for _, k := range []string{"settings.room_below_pct.invalid", "licence.a.near-limit"} {
|
||||
rt.seedState(licences.EntryConditions, k, licences.KeptCondition{Body: oc.Condition{Key: k, Headline: "h"},
|
||||
Since: "2026-11-12T10:00:00Z"})
|
||||
}
|
||||
var keys []string
|
||||
for _, c := range run(t, rt.reader(), "licences_conditions", nil).([]any) {
|
||||
f := c.(map[string]any)
|
||||
if f["since"] == "" || f["body"].(map[string]any)["headline"] == "" {
|
||||
t.Fatalf("condition %v", f)
|
||||
}
|
||||
keys = append(keys, f["body"].(map[string]any)["key"].(string))
|
||||
}
|
||||
if strings.Join(keys, ",") != "licence.a.near-limit,settings.room_below_pct.invalid" {
|
||||
t.Fatalf("keys %v", keys)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}
|
||||
@@ -3,5 +3,11 @@
|
||||
"floor_minutes": 60,
|
||||
"failures_to_notify": 3,
|
||||
"cooldown_hours": 24,
|
||||
"refresh_warn_days": 3
|
||||
"refresh_warn_days": 3,
|
||||
"switch_auto": true,
|
||||
"switch_at_pct": 99,
|
||||
"room_below_pct": 90,
|
||||
"warn_at_pct": 90,
|
||||
"usage_minutes": 5,
|
||||
"usage_stale_minutes": 15
|
||||
}
|
||||
|
||||
@@ -3,20 +3,22 @@ module claude-licence-manager
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/nats-io/nats.go v1.54.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user