Compare commits
559
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05b3e2e1b9 | ||
|
|
f94abb95aa | ||
|
|
e5d89c5eed | ||
|
|
3526e9ef2a | ||
|
|
be1ffdfedb | ||
|
|
d420491ca1 | ||
|
|
232f38de99 | ||
|
|
c54648e743 | ||
|
|
719956b745 | ||
|
|
eb2978b3ce | ||
|
|
5e67b548a0 | ||
|
|
aef9957450 | ||
|
|
c4de50e91e | ||
|
|
64e83c7ee2 | ||
|
|
7eba326d3d | ||
|
|
0cc28f5b1f | ||
|
|
c974a11c97 | ||
|
|
09cf1ea6fc | ||
|
|
28ce325c2e | ||
|
|
fe83d66b62 | ||
|
|
712b21bd74 | ||
|
|
dae61959b9 | ||
|
|
4827df4d48 | ||
|
|
c10ef07ea3 | ||
|
|
3218da4ee4 | ||
|
|
635aed57fd | ||
|
|
1a3ef8101f | ||
|
|
5bb0662d19 | ||
|
|
315d7e9b8c | ||
|
|
0424e87399 | ||
|
|
f9e035fbee | ||
|
|
85b31412b9 | ||
|
|
3273f5b219 | ||
|
|
54e1ecce45 | ||
|
|
c5fc2ee859 | ||
|
|
a0465bf0b7 | ||
|
|
e802e67f9a | ||
|
|
c1d7218b32 | ||
|
|
a410447ff5 | ||
|
|
7faa4a1b20 | ||
|
|
44a97d9ad6 | ||
|
|
ce592a94d0 | ||
|
|
5bc1bf9f0a | ||
|
|
772b441be3 | ||
|
|
d15c1bc814 | ||
|
|
de3af0cc65 | ||
|
|
3249b30e8d | ||
|
|
7efb17386d | ||
|
|
8d883c373e | ||
|
|
1a149d44be | ||
|
|
e39faa0f38 | ||
|
|
293df49377 | ||
|
|
fc50486190 | ||
|
|
0b25bce239 | ||
|
|
6fb778a889 | ||
|
|
3622c129f8 | ||
|
|
f30a53b242 | ||
|
|
c515074714 | ||
|
|
e1f4ac1050 | ||
|
|
42ac1a9a0a | ||
|
|
effd0c00a6 | ||
|
|
1027803e1d | ||
|
|
3ff694e425 | ||
|
|
da653ce593 | ||
|
|
0bb3a2c392 | ||
|
|
253bd06970 | ||
|
|
6bfd14b14b | ||
|
|
231566eba8 | ||
|
|
5e440129db | ||
|
|
640e68f86e | ||
|
|
f98e159446 | ||
|
|
2f732b22d0 | ||
|
|
c60a46aa6a | ||
|
|
400f4e0020 | ||
|
|
8a84711792 | ||
|
|
c9fec158cb | ||
|
|
df5cef0a67 | ||
|
|
46baac8286 | ||
|
|
63f983897c | ||
|
|
5e02dccb98 | ||
|
|
6c99075ab9 | ||
|
|
7d05a39c0c | ||
|
|
ba4220ad7d | ||
|
|
5a38a12216 | ||
|
|
85ba0a9a70 | ||
|
|
73c8f15151 | ||
|
|
52e564845e | ||
|
|
b7abe203a9 | ||
|
|
cf5812101b | ||
|
|
fdedf290d5 | ||
|
|
6e2f08f96a | ||
|
|
9ab75bab49 | ||
|
|
413dd13fe3 | ||
|
|
1b9475e15f | ||
|
|
493e69e883 | ||
|
|
636c2c1b0c | ||
|
|
439b77e07d | ||
|
|
6655bdff50 | ||
|
|
189f28e4e1 | ||
|
|
f6e214533b | ||
|
|
b6cc9572f2 | ||
|
|
4087280895 | ||
|
|
2341e8e983 | ||
|
|
f8bd12e4b7 | ||
|
|
15fc2c4ce4 | ||
|
|
092260870b | ||
|
|
cc64794d4c | ||
|
|
25c08d09ec | ||
|
|
d3e10f68c5 | ||
|
|
6ec6cad968 | ||
|
|
2122ba52c0 | ||
|
|
510d983cc6 | ||
|
|
e9c319c5a0 | ||
|
|
0bec849c40 | ||
|
|
779ad9ac10 | ||
|
|
7c015b652d | ||
|
|
f02f142af1 | ||
|
|
1c7356c247 | ||
|
|
f4bbcd7e5e | ||
|
|
1c684743f7 | ||
|
|
43d499b0b3 | ||
|
|
155669e69c | ||
|
|
3e5228af46 | ||
|
|
b4ade2c502 | ||
|
|
018243a86c | ||
|
|
318b0dee69 | ||
|
|
52bd3d087c | ||
|
|
029ba98527 | ||
|
|
bb59fd8fa3 | ||
|
|
ba82dd64b1 | ||
|
|
acef75d398 | ||
|
|
8dc610c628 | ||
|
|
b56841596e | ||
|
|
d158ac52da | ||
|
|
346dd43228 | ||
|
|
04d951d9da | ||
|
|
3d87ddcc9a | ||
|
|
21dd8881ed | ||
|
|
d6c87ee1a7 | ||
|
|
c459f75730 | ||
|
|
036266daff | ||
|
|
abf5eace77 | ||
|
|
f569924037 | ||
|
|
c94f30d398 | ||
|
|
729a86b623 | ||
|
|
fb13f3cc7e | ||
|
|
66232896f3 | ||
|
|
8757567a79 | ||
|
|
dcb2e6e966 | ||
|
|
ba1e90fb2d | ||
|
|
e240e01d07 | ||
|
|
7b034e1db0 | ||
|
|
49b307a46d | ||
|
|
8295207077 | ||
|
|
4a4fac090e | ||
|
|
dff0ce2abb | ||
|
|
c816666f83 | ||
|
|
2d717777b1 | ||
|
|
f913918a60 | ||
|
|
38a0bbca44 | ||
|
|
79ad8b539c | ||
|
|
90a5fc5797 | ||
|
|
362dddae40 | ||
|
|
9169830210 | ||
|
|
8c2ce57eee | ||
|
|
69270a30d5 | ||
|
|
d7a00e1f26 | ||
|
|
2ee75954ad | ||
|
|
959f57294a | ||
|
|
59b4c7910b | ||
|
|
5758e9fb57 | ||
|
|
c21d8fd969 | ||
|
|
bbfda29b49 | ||
|
|
3ed41f02d5 | ||
|
|
09a5eb5783 | ||
|
|
a4ed4e6742 | ||
|
|
d7a008bee5 | ||
|
|
feadebd331 | ||
|
|
6f483725e9 | ||
|
|
fdbb3d5784 | ||
|
|
ea8c50136c | ||
|
|
b2a27ed5ce | ||
|
|
5d26c658f5 | ||
|
|
621984614d | ||
|
|
0ef7c0caa4 | ||
|
|
67354f8e01 | ||
|
|
71be15f530 | ||
|
|
a6c6d936d5 | ||
|
|
cad8250aaa | ||
|
|
790d1c82d0 | ||
|
|
8696aa9ba4 | ||
|
|
db4eb3bbf4 | ||
|
|
da41823855 | ||
|
|
8af6e72669 | ||
|
|
62cddf4efc | ||
|
|
19c204273b | ||
|
|
a20bdf7952 | ||
|
|
4dfc11b424 | ||
|
|
68aaf235d4 | ||
|
|
25a2cecd98 | ||
|
|
7cab079e2e | ||
|
|
9904a03b51 | ||
|
|
7b09de1e55 | ||
|
|
7631a7d5de | ||
|
|
a343557554 | ||
|
|
ccb367e68c | ||
|
|
ff5e00f1f8 | ||
|
|
c28c645ca4 | ||
|
|
5ae69df8cc | ||
|
|
cbbfbf3d2d | ||
|
|
fea48f19ce | ||
|
|
bb01a656d0 | ||
|
|
b611022457 | ||
|
|
fbbfae7629 | ||
|
|
acbf80ad91 | ||
|
|
de8e82c06f | ||
|
|
085f17e935 | ||
|
|
2d1d8def75 | ||
|
|
98d5306d6b | ||
|
|
a4e7629742 | ||
|
|
a320f8e2cd | ||
|
|
7a9c8570cb | ||
|
|
4c54aec972 | ||
|
|
d352fe51b4 | ||
|
|
712ec9481b | ||
|
|
50ea6f748e | ||
|
|
748543c35f | ||
|
|
dfd5d8295e | ||
|
|
dec3bfe7a8 | ||
|
|
8ed3b872e0 | ||
|
|
6bce1fb0d2 | ||
|
|
409c56685d | ||
|
|
b9ef1e4fcb | ||
|
|
17fc9b9b81 | ||
|
|
125bb91771 | ||
|
|
6c5a8ef893 | ||
|
|
91aa7b78fc | ||
|
|
b74e23f973 | ||
|
|
eff81f1220 | ||
|
|
6a71d29b33 | ||
|
|
4e9621edd6 | ||
|
|
0cdbdca139 | ||
|
|
14de8dadb4 | ||
|
|
9685990a15 | ||
|
|
d539d7e4eb | ||
|
|
c1bc21c35f | ||
|
|
16932c93d6 | ||
|
|
754edfae69 | ||
|
|
4c1384faef | ||
|
|
a37181261c | ||
|
|
206f794eb3 | ||
|
|
ef1ec1c935 | ||
|
|
942f906222 | ||
|
|
61b67d61e2 | ||
|
|
44dbb22ea7 | ||
|
|
1dc5d8cb3d | ||
|
|
7115b7bf64 | ||
|
|
e703e9b0e2 | ||
|
|
7b02f63daf | ||
|
|
59311407e2 | ||
|
|
1d9de5dbb6 | ||
|
|
af66ccb833 | ||
|
|
42988a734b | ||
|
|
6e78203959 | ||
|
|
d0625eea3d | ||
|
|
9379e9a96a | ||
|
|
0c99e847a2 | ||
|
|
83cf97b690 | ||
|
|
46d4b4da5e | ||
|
|
2b1e148bf5 | ||
|
|
2da0de75c3 | ||
|
|
044ead7db8 | ||
|
|
78c9ec8817 | ||
|
|
6e4a3014cb | ||
|
|
007b6fd45f | ||
|
|
8fe3467753 | ||
|
|
fa5a6aeff5 | ||
|
|
31e90aaa0f | ||
|
|
7bd0f6c2bd | ||
|
|
f3677eb6f3 | ||
|
|
992ea0ab66 | ||
|
|
85f5fd0b22 | ||
|
|
9f0bb1cfa4 | ||
|
|
08fc70783a | ||
|
|
bb993dd606 | ||
|
|
196c5b6af2 | ||
|
|
dc78524e38 | ||
|
|
f9a496d3e1 | ||
|
|
7fdbadc655 | ||
|
|
6eae0765f4 | ||
|
|
94d0de5c19 | ||
|
|
58a83249c8 | ||
|
|
6b8c694381 | ||
|
|
dedd988fc9 | ||
|
|
f701db0516 | ||
|
|
161fba56ae | ||
|
|
692dbf9a17 | ||
|
|
66ff33dbd3 | ||
|
|
6e39ed1852 | ||
|
|
56514aa4a2 | ||
|
|
4a967933b3 | ||
|
|
233b6be5dd | ||
|
|
4204afd240 | ||
|
|
2d2aa7ba9b | ||
|
|
2e5cc4f166 | ||
|
|
bc6c8a6d24 | ||
|
|
20cac82ab7 | ||
|
|
fbd00c8943 | ||
|
|
7df90445ad | ||
|
|
32f5ce3712 | ||
|
|
8e3d7a4c3c | ||
|
|
b6ef01d6c6 | ||
|
|
7000bd5049 | ||
|
|
69fcb6c1c1 | ||
|
|
2a04bf0ac5 | ||
|
|
0b12b62a01 | ||
|
|
5a47192b57 | ||
|
|
83d928fe50 | ||
|
|
544720b00a | ||
|
|
575a05e49e | ||
|
|
4fff214eeb | ||
|
|
187fb63439 | ||
|
|
22c9ddd32b | ||
|
|
94cee5d199 | ||
|
|
52056010fe | ||
|
|
307b6234e6 | ||
|
|
dbcaa29a1a | ||
|
|
394fec8a63 | ||
|
|
c2999a4e13 | ||
|
|
d4c61ee8ff | ||
|
|
76f7c4d223 | ||
|
|
f609be7864 | ||
|
|
d747c1128e | ||
|
|
f9afe32888 | ||
|
|
77b48a4f1a | ||
|
|
e84a121b8a | ||
|
|
06b13ec1cc | ||
|
|
0830fcb25e | ||
|
|
b03bf4c118 | ||
|
|
53f2649369 | ||
|
|
8eafc0c5ce | ||
|
|
50de0a5a71 | ||
|
|
2323ba19db | ||
|
|
63607ba169 | ||
|
|
82c54870ed | ||
|
|
79231a9e6c | ||
|
|
222d605f6f | ||
|
|
3a8997c58a | ||
|
|
d6db3f38e6 | ||
|
|
18a47ac3c7 | ||
|
|
1386219d4d | ||
|
|
10d4d2f845 | ||
|
|
b3100c55a1 | ||
|
|
0062bdc4a6 | ||
|
|
a1a688b19e | ||
|
|
54b17164ef | ||
|
|
3ed9a0e342 | ||
|
|
9b5657a3d6 | ||
|
|
1d4637e46b | ||
|
|
fefa8b88ea | ||
|
|
8d9b3f779d | ||
|
|
da506bb3b8 | ||
|
|
af08be36a2 | ||
|
|
6b43afa2ab | ||
|
|
f91340fe82 | ||
|
|
6aeb401095 | ||
|
|
5228a5782d | ||
|
|
9e5666b4d6 | ||
|
|
c006aa8242 | ||
|
|
d7c790d242 | ||
|
|
38dbe1d3a3 | ||
|
|
a2dbcf670b | ||
|
|
e987a8280b | ||
|
|
45f5f76811 | ||
|
|
4b4a173d84 | ||
|
|
0282229fd9 | ||
|
|
5a2a7eebfd | ||
|
|
004bbc2ce2 | ||
|
|
fed64292f0 | ||
|
|
7a82384e21 | ||
|
|
7c565f2aa5 | ||
|
|
edc6f269f3 | ||
|
|
902e3c9e96 | ||
|
|
c16e6213e1 | ||
|
|
98f2f24069 | ||
|
|
0de3006415 | ||
|
|
7aedd28f01 | ||
|
|
f387581b6b | ||
|
|
eda82b1598 | ||
|
|
06742d0448 | ||
|
|
3d021445f7 | ||
|
|
1618837ea9 | ||
|
|
6097e4f3e0 | ||
|
|
8520a4c099 | ||
|
|
70de9429e5 | ||
|
|
79eb58efe4 | ||
|
|
3028c7ec4e | ||
|
|
1062b33d32 | ||
|
|
16b8d8a1ee | ||
|
|
be77f66563 | ||
|
|
362a3f1378 | ||
|
|
03da552cb3 | ||
|
|
4e0c57771e | ||
|
|
b31c4580aa | ||
|
|
47eb100992 | ||
|
|
7359d187db | ||
|
|
47b1dcf08f | ||
|
|
e32d777530 | ||
|
|
472d5f3144 | ||
|
|
2fd5ffdd62 | ||
|
|
d48365aed0 | ||
|
|
7a8e9f9706 | ||
|
|
d7a758894a | ||
|
|
04de3e5da2 | ||
|
|
9f92233a28 | ||
|
|
8ad9372b19 | ||
|
|
3236cbad43 | ||
|
|
a262c7dbe0 | ||
|
|
05b6783103 | ||
|
|
56de5ba840 | ||
|
|
43eef46c41 | ||
|
|
cadd7b1b2f | ||
|
|
062c29c3a2 | ||
|
|
a37d2622ed | ||
|
|
ba398e2460 | ||
|
|
82f10c3bc9 | ||
|
|
7e99c354ac | ||
|
|
8e81d87610 | ||
|
|
db1007ffdd | ||
|
|
981a35b598 | ||
|
|
b0cbc4e6ac | ||
|
|
a2759ec92e | ||
|
|
f178323b57 | ||
|
|
684acb922d | ||
|
|
e78e881ecb | ||
|
|
68d65e6bbf | ||
|
|
823d52051f | ||
|
|
b4310dfb8e | ||
|
|
9796444201 | ||
|
|
7187beaf04 | ||
|
|
5f45175ed0 | ||
|
|
18e541c1ba | ||
|
|
cf930388f9 | ||
|
|
36ae0c691b | ||
|
|
2cbe219371 | ||
|
|
3a095a66be | ||
|
|
805b4d9a35 | ||
|
|
d00dac140b | ||
|
|
e44cbaf57f | ||
|
|
1f70c4c0aa | ||
|
|
5ecda46124 | ||
|
|
aa8e9afc7c | ||
|
|
54a8afac73 | ||
|
|
43de3c937d | ||
|
|
03dce12378 | ||
|
|
dae8e288d2 | ||
|
|
dd9d9d5b5f | ||
|
|
2c0ab3a592 | ||
|
|
47a66e3c0d | ||
|
|
3968849b1b | ||
|
|
02f50bd9d6 | ||
|
|
81ec7bd7de | ||
|
|
fe11db9293 | ||
|
|
e40dd0f56a | ||
|
|
9713c3023a | ||
|
|
53c2def1a5 | ||
|
|
f55b34febc | ||
|
|
944a35d200 | ||
|
|
7d81e8326f | ||
|
|
91413de335 | ||
|
|
6447a1c6d4 | ||
|
|
4d5f430224 | ||
|
|
dfe3ba9475 | ||
|
|
f1d7c7377d | ||
|
|
22bc4dc8ef | ||
|
|
b72de2b75a | ||
|
|
6df0dfe208 | ||
|
|
9532b78724 | ||
|
|
e49309d61c | ||
|
|
9789c8379a | ||
|
|
2b0689f654 | ||
|
|
5b1e990eb4 | ||
|
|
a431746c0e | ||
|
|
5db53e738f | ||
|
|
9f7f50de8d | ||
|
|
bdd4c7b875 | ||
|
|
e982072443 | ||
|
|
146cd858a3 | ||
|
|
a7a1fbd34e | ||
|
|
1888d1d230 | ||
|
|
960d6c34d2 | ||
|
|
ff81bdf8cc | ||
|
|
65ee43f075 | ||
|
|
c3e8fc4ba6 | ||
|
|
75be8acbad | ||
|
|
d103ed88ab | ||
|
|
3ac644baef | ||
|
|
fd7d93333e | ||
|
|
d515c71e1c | ||
|
|
36061b5c56 | ||
|
|
4ef49d0391 | ||
|
|
17b7cc480d | ||
|
|
6ac8c5c229 | ||
|
|
21252194d5 | ||
|
|
545a0c9cb4 | ||
|
|
527d87613e | ||
|
|
36c92861d2 | ||
|
|
852ba81c5a | ||
|
|
3203358c4c | ||
|
|
9b01c94a9e | ||
|
|
33395f68b3 | ||
|
|
45564e69a6 | ||
|
|
d96b9fd660 | ||
|
|
2aae6f5580 | ||
|
|
6b84973142 | ||
|
|
e7f0dc612e | ||
|
|
76deab6c67 | ||
|
|
e66c6fea62 | ||
|
|
6b1c7a5613 | ||
|
|
04902ff24a | ||
|
|
bb71310337 | ||
|
|
18e0a01c54 | ||
|
|
ddafe1b1aa | ||
|
|
b4cd95e7c4 | ||
|
|
89c5838176 | ||
|
|
c2a410bb63 | ||
|
|
af66adc180 | ||
|
|
9c61918ac3 | ||
|
|
83ee7769d8 | ||
|
|
175a5567b8 | ||
|
|
1747899419 | ||
|
|
f5c233a928 | ||
|
|
0abe480dba | ||
|
|
85fa8511c4 | ||
|
|
79649c3fa9 | ||
|
|
c3544b6a17 | ||
|
|
136fa8b2a8 | ||
|
|
8f6ee2fd18 | ||
|
|
095d41fa3e | ||
|
|
b7b5c6ef27 | ||
|
|
7ac4493ce9 | ||
|
|
881da7f599 | ||
|
|
3db051be57 | ||
|
|
e0d1de0475 | ||
|
|
b5f8604d03 | ||
|
|
0be0ffd615 | ||
|
|
f63fa86c74 | ||
|
|
db26f7e8b6 | ||
|
|
d4ae4b6790 | ||
|
|
2f6741b7f8 | ||
|
|
20a6dc4920 | ||
|
|
c4f37c279a | ||
|
|
7f89cb51de | ||
|
|
518dae5ec9 | ||
|
|
e8f8ef63fa | ||
|
|
4f4d5ca9c9 | ||
|
|
a8fbabbfc3 | ||
|
|
b9911dc1c1 | ||
|
|
e6b2df6506 |
@@ -9,6 +9,15 @@ does not belong.* Per-module code (a service's API client, its tool implementati
|
|||||||
create-a-resource adapter) lives **in the module**, never here — that coupling is exactly what
|
create-a-resource adapter) lives **in the module**, never here — that coupling is exactly what
|
||||||
turned the old SDK into constant maintenance and made every edit rebuild every module.
|
turned the old SDK into constant maintenance and made every edit rebuild every module.
|
||||||
|
|
||||||
|
**A seat's code is not here** (novox/hq ADR 0625, ADR 0627). mesh-sdk is the runtime every module uses (the bus
|
||||||
|
connection, the tools bundle's runtime, state and its helpers) and a seat-neutral part two or more seats' clients or
|
||||||
|
holders share (sending a file in parts, `go/parts`). A seat defined whole has its generated contract, and its client
|
||||||
|
where callers share logic over it, in mesh-contracts (`go/seats/<seat>`, `go/clients/<seat>client`); the seat list is
|
||||||
|
mesh-contracts' `go/seats`, which `catalogue` alone imports. The value layouts of seats not yet defined whole stay
|
||||||
|
here until their domain moves, and no new seat-specific type is added. `packages_list_test.go` holds all of it:
|
||||||
|
mesh-sdk imports no contracts module but the seat set in `catalogue`, names none of the seats with a client outside
|
||||||
|
`catalogue`, and holds none of the packages that moved (`asks`, `asker`, `publish`).
|
||||||
|
|
||||||
## What is in it
|
## What is in it
|
||||||
|
|
||||||
Five small areas, each a thing a module's `tools/` or `provisioner/` imports:
|
Five small areas, each a thing a module's `tools/` or `provisioner/` imports:
|
||||||
@@ -106,21 +115,20 @@ runtime's (`node-tools/internal/launch`, mesh-tools).
|
|||||||
values and then every change. Through the runtime that launched the bundle, which refuses what the
|
values and then every change. Through the runtime that launched the bundle, which refuses what the
|
||||||
module did not declare. Current values, never history and never a secret (novox/hq ADR 0201).
|
module did not declare. Current values, never history and never a secret (novox/hq ADR 0201).
|
||||||
|
|
||||||
### `seats` and `asks` — asking a person, answering on a channel (Go)
|
### `seats` and `channel` — publishing on a seat, the channel's benches (Go)
|
||||||
A seat may name its caller or its kind (novox/hq ADR 0259): `stdio.SeatPublish` submits an accept or says
|
A seat may name its caller or its kind (novox/hq ADR 0259): `stdio.SeatPublish` submits an accept or says
|
||||||
an event under the module's own name or kind, `stdio.SeatTake` takes a held seat's work, `stdio.SeatProve`
|
an event under the module's own name or kind, `stdio.SeatTake` takes a held seat's work, `stdio.SeatProve`
|
||||||
and `stdio.SeatAnswer` carry a proof (a code typed by a person, by request and reply, never kept),
|
and `stdio.SeatAnswer` carry a proof (a code typed by a person, by request and reply, never kept),
|
||||||
`stdio.SeatRecord` reads a record kept for the module, and `State(...).Create` and `.Update` write by
|
`stdio.SeatRecord` reads a record kept for the module, and `State(...).Create` and `.Update` write by
|
||||||
compare-and-set. The runtime does each only as far as the module's membership lists. `go/asks` is the
|
compare-and-set. The runtime does each only as far as the module's membership lists. `go/channel` holds the
|
||||||
contract on top: the `Ask` an asker publishes and the checks it is held to (its `Whole` is what a channel that proves
|
shapes of the two benches a channel holds, `channel` and `intake` (the `Message` a channel takes, the `Choice`,
|
||||||
who answers shows in place of its `Explanation`, and its `Details` what the Details answer shows, hq issue 383), the `Warrant` it acts on
|
`LinkAsked`, `Failed`, `Standing` and `Code` it says, with the subjects each travels on): those benches are not
|
||||||
(`For`, `Says`), and the `Message`, `Choice`, `LinkAsked` and `Code` a channel takes and says, with the
|
defined whole, so their layouts stay here (ADR 0625 §9).
|
||||||
subjects each travels on.
|
Asking a person is the `operator-channel` seat's, defined whole in mesh-contracts: its `Ask` and `Warrant` are its
|
||||||
`go/asker` is an asking module's client (ADR 0259 §10): `Ask` keeps an ask in the module's own state and then
|
generated contract (`go/seats/operatorchannel`), and the checks an ask is held to, the warrant's (`For`, `Says`,
|
||||||
publishes it under the module's name; `Cancel`; `Where` reads its state from the router's record; `Listen` and
|
`Performs`) and an asking module's client (`Ask`, `Cancel`, `Where`, `Listen`, `Take`, each warrant handed once,
|
||||||
`Take` hand the module each warrant once — marked acted on by compare-and-set before the module acts, and only
|
marked acted on by compare-and-set) are its client, `go/clients/operatorchannelclient` (ADR 0627 §1). A lost
|
||||||
for an ask it made, an option it offered, at that option's level, before it expired. A lost compare-and-set is
|
compare-and-set is `stdio.ErrChanged`, told by the runtime's error code.
|
||||||
`stdio.ErrChanged`, told by the runtime's error code.
|
|
||||||
`State(...).Increment(key, attempts)` is a counter on top of the two (for numbers handed out once, such as an
|
`State(...).Increment(key, attempts)` is a counter on top of the two (for numbers handed out once, such as an
|
||||||
issue's): it reads the key, writes one more at the revision read, and on a lost write reads and tries again, at
|
issue's): it reads the key, writes one more at the revision read, and on a lost write reads and tries again, at
|
||||||
most `attempts` times; past that it is `stdio.ErrChanged` and nothing was counted.
|
most `attempts` times; past that it is `stdio.ErrChanged` and nothing was counted.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
example
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
CFLAGS ?= -O2 -Wall -Wextra
|
|
||||||
JANSSON := $(shell pkg-config --cflags --libs jansson)
|
|
||||||
|
|
||||||
example: example.c mesh_sdk.c mesh_sdk.h
|
|
||||||
$(CC) $(CFLAGS) -o $@ example.c mesh_sdk.c $(JANSSON)
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -f example
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# mesh-sdk for C
|
|
||||||
|
|
||||||
The skeleton (novox/hq ADR 0188): `mesh_serve(name, tools, count)` speaks MCP over stdio to the
|
|
||||||
node's tool runtime. A `mesh_tool` is a name, a description, an argument schema and a function
|
|
||||||
returning JSON. A tool named `<seat>.<verb>` is the seat's implementation. JSON is jansson's, the
|
|
||||||
one dependency.
|
|
||||||
|
|
||||||
What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no
|
|
||||||
client, nothing of any module's. `make` and pipe a `tools/list` request into `./example`.
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
/* A tools bundle in C: one tool of its own and one seat verb, served over stdio. */
|
|
||||||
#include "mesh_sdk.h"
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
static json_t *greet(json_t *args, char **error) {
|
|
||||||
(void)error;
|
|
||||||
const char *who = json_string_value(json_object_get(args, "who"));
|
|
||||||
char greeting[128];
|
|
||||||
snprintf(greeting, sizeof greeting, "hello %s", who ? who : "world");
|
|
||||||
return json_pack("{s:s,s:s}", "greeting", greeting, "language", "c");
|
|
||||||
}
|
|
||||||
|
|
||||||
static json_t *on(json_t *args, char **error) {
|
|
||||||
(void)args; (void)error;
|
|
||||||
return json_pack("{s:b,s:s}", "on", 1, "language", "c");
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(void) {
|
|
||||||
json_t *who = json_pack("{s:{s:s,s:s}}", "who", "type", "string", "description", "whom");
|
|
||||||
const mesh_tool tools[] = {
|
|
||||||
{"greet", "say hello", who, greet},
|
|
||||||
{"node-lamp.on", "the seat's verb", NULL, on},
|
|
||||||
};
|
|
||||||
int rc = mesh_serve("c-example", tools, 2);
|
|
||||||
json_decref(who);
|
|
||||||
return rc;
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
#include "mesh_sdk.h"
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
static void say(json_t *m) {
|
|
||||||
json_object_set_new(m, "jsonrpc", json_string("2.0"));
|
|
||||||
char *text = json_dumps(m, JSON_COMPACT);
|
|
||||||
fprintf(stdout, "%s\n", text);
|
|
||||||
fflush(stdout);
|
|
||||||
free(text);
|
|
||||||
json_decref(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
static json_t *as_schema(json_t *given) {
|
|
||||||
if (!given || !json_is_object(given) || json_object_size(given) == 0)
|
|
||||||
return json_pack("{s:s,s:{}}", "type", "object", "properties");
|
|
||||||
if (json_object_get(given, "type") || json_object_get(given, "properties"))
|
|
||||||
return json_incref(given);
|
|
||||||
return json_pack("{s:s,s:O}", "type", "object", "properties", given);
|
|
||||||
}
|
|
||||||
|
|
||||||
int mesh_serve(const char *name, const mesh_tool *tools, size_t count) {
|
|
||||||
char *line = NULL;
|
|
||||||
size_t cap = 0;
|
|
||||||
ssize_t got;
|
|
||||||
while ((got = getline(&line, &cap, stdin)) >= 0) {
|
|
||||||
json_error_t err;
|
|
||||||
json_t *req = json_loads(line, 0, &err);
|
|
||||||
if (!req) continue;
|
|
||||||
json_t *id = json_object_get(req, "id");
|
|
||||||
const char *method = json_string_value(json_object_get(req, "method"));
|
|
||||||
json_t *params = json_object_get(req, "params");
|
|
||||||
if (!method) { json_decref(req); continue; }
|
|
||||||
if (strcmp(method, "initialize") == 0) {
|
|
||||||
say(json_pack("{s:O,s:{s:s,s:{s:{}},s:{s:s,s:s}}}", "id", id ? id : json_null(), "result",
|
|
||||||
"protocolVersion", MESH_SDK_PROTOCOL, "capabilities", "tools",
|
|
||||||
"serverInfo", "name", name, "version", "1"));
|
|
||||||
} else if (strcmp(method, "notifications/initialized") == 0) {
|
|
||||||
} else if (strcmp(method, "ping") == 0) {
|
|
||||||
if (id) say(json_pack("{s:O,s:{}}", "id", id, "result"));
|
|
||||||
} else if (strcmp(method, "tools/list") == 0) {
|
|
||||||
json_t *listed = json_array();
|
|
||||||
for (size_t i = 0; i < count; i++)
|
|
||||||
json_array_append_new(listed, json_pack("{s:s,s:s,s:o}", "name", tools[i].name,
|
|
||||||
"description", tools[i].description, "inputSchema", as_schema(tools[i].input)));
|
|
||||||
say(json_pack("{s:O,s:{s:o}}", "id", id ? id : json_null(), "result", "tools", listed));
|
|
||||||
} else if (strcmp(method, "tools/call") == 0) {
|
|
||||||
const char *wanted = params ? json_string_value(json_object_get(params, "name")) : NULL;
|
|
||||||
const mesh_tool *tool = NULL;
|
|
||||||
for (size_t i = 0; wanted && i < count; i++)
|
|
||||||
if (strcmp(tools[i].name, wanted) == 0) tool = &tools[i];
|
|
||||||
if (!tool) {
|
|
||||||
char message[256];
|
|
||||||
snprintf(message, sizeof message, "%s has no tool %s", name, wanted ? wanted : "");
|
|
||||||
say(json_pack("{s:O,s:{s:i,s:s}}", "id", id ? id : json_null(), "error", "code", -32602, "message", message));
|
|
||||||
} else {
|
|
||||||
json_t *args = params ? json_object_get(params, "arguments") : NULL;
|
|
||||||
json_t *empty = args ? NULL : json_object();
|
|
||||||
char *error = NULL;
|
|
||||||
json_t *result = tool->run(args ? args : empty, &error);
|
|
||||||
if (empty) json_decref(empty);
|
|
||||||
if (result) {
|
|
||||||
char *text = json_dumps(result, JSON_COMPACT);
|
|
||||||
say(json_pack("{s:O,s:{s:[{s:s,s:s}]}}", "id", id ? id : json_null(), "result", "content", "type", "text", "text", text));
|
|
||||||
free(text);
|
|
||||||
json_decref(result);
|
|
||||||
} else {
|
|
||||||
say(json_pack("{s:O,s:{s:[{s:s,s:s}],s:b}}", "id", id ? id : json_null(), "result", "content",
|
|
||||||
"type", "text", "text", error ? error : "the tool failed", "isError", 1));
|
|
||||||
free(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (id) {
|
|
||||||
char message[256];
|
|
||||||
snprintf(message, sizeof message, "no %s", method);
|
|
||||||
say(json_pack("{s:O,s:{s:i,s:s}}", "id", id, "error", "code", -32601, "message", message));
|
|
||||||
}
|
|
||||||
json_decref(req);
|
|
||||||
}
|
|
||||||
free(line);
|
|
||||||
return ferror(stdin) ? -1 : 0;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/* The Novox Mesh SDK for C — the skeleton (novox/hq ADR 0188).
|
|
||||||
*
|
|
||||||
* A tools bundle is a process the node's runtime launches and speaks MCP over stdio to:
|
|
||||||
* `initialize`, `tools/list` once, `tools/call` per call, newline-framed JSON-RPC on stdin and
|
|
||||||
* stdout. This is the loop, so a bundle writes its tools and nothing else. A tool named
|
|
||||||
* `<seat>.<verb>` is the module's implementation of that seat's verb; any other name is the
|
|
||||||
* module's own. JSON is jansson's.
|
|
||||||
*
|
|
||||||
* No transport here: the bus, the subjects and the memberships are the runtime's (ADR 0039, 0175).
|
|
||||||
* stdout is the protocol; log to stderr. */
|
|
||||||
#ifndef MESH_SDK_H
|
|
||||||
#define MESH_SDK_H
|
|
||||||
#include <jansson.h>
|
|
||||||
|
|
||||||
#define MESH_SDK_PROTOCOL "2025-03-26"
|
|
||||||
|
|
||||||
/* One tool. `run` returns a new JSON value (the answer) or NULL with *error set to a message the
|
|
||||||
* caller frees. `input` is the schema of the arguments, or NULL for a tool that takes nothing. */
|
|
||||||
typedef struct {
|
|
||||||
const char *name;
|
|
||||||
const char *description;
|
|
||||||
json_t *input;
|
|
||||||
json_t *(*run)(json_t *args, char **error);
|
|
||||||
} mesh_tool;
|
|
||||||
|
|
||||||
/* Serve these tools on stdin/stdout until stdin closes. Returns 0, or -1 on an I/O fault. */
|
|
||||||
int mesh_serve(const char *name, const mesh_tool *tools, size_t count);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
+1
-1
@@ -16,6 +16,6 @@ at the same version as the TypeScript package.
|
|||||||
|
|
||||||
What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol
|
What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol
|
||||||
`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one
|
`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one
|
||||||
answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here and the TypeScript `@novox/mesh-sdk/seal` makes. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it.
|
answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here. Nothing else — no bus, no client, nothing of any module's.
|
||||||
|
|
||||||
From release 4a of design 47 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4) it also holds the composition of a machine's declaration, over a plain input and with nothing behind it: `declaration` and `validate`, the node-engine's parser and its validator, copied from mesh-host; `catalogue`, the manifests, their resolution and rendering, and `overlay`, the private network's generator, copied from mesh-controller; `compose`, the controller's planFor, renderingFor and declarationWith as functions of an `Input` (the composition never allocates, makes, keeps or writes: what a send would do it names as foreseen or returns); `sdkversion`, which mesh-sdk version a program was built with; and, in `delivery`, the layouts of the keys the builder, the checker and the indexer write (the source fingerprint's new form, the builds, the verdicts and kept verdicts, the manifests by digest). None of them holds a bus, a store or a vault client, which `go list -deps` of `compose` shows.
|
From release 4a of design 47 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4) it also holds the composition of a machine's declaration, over a plain input and with nothing behind it: `declaration` and `validate`, the node-engine's parser and its validator, copied from mesh-host; `catalogue`, the manifests, their resolution and rendering, and `overlay`, the private network's generator, copied from mesh-controller; `compose`, the controller's planFor, renderingFor and declarationWith as functions of an `Input` (the composition never allocates, makes, keeps or writes: what a send would do it names as foreseen or returns); `sdkversion`, which mesh-sdk version a program was built with; and, in `delivery`, the layouts of the keys the builder, the checker and the indexer write (the source fingerprint's new form, the builds, the verdicts and kept verdicts, the manifests by digest). None of them holds a bus, a store or a vault client, which `go list -deps` of `compose` shows.
|
||||||
|
|||||||
+32
-6
@@ -24,16 +24,13 @@ const Event = "states"
|
|||||||
// account may already publish to and the events stream keeps.
|
// account may already publish to and the events stream keeps.
|
||||||
func Subject(module string) string { return "mesh.mod." + module + ".event." + Event }
|
func Subject(module string) string { return "mesh.mod." + module + ".event." + Event }
|
||||||
|
|
||||||
// SubjectAll is every module's announcement, what the controller consumes.
|
// SubjectAll is every module's announcement.
|
||||||
const SubjectAll = "mesh.mod.*.event." + Event
|
const SubjectAll = "mesh.mod.*.event." + Event
|
||||||
|
|
||||||
// KeptBucket is the controller's bucket of the last announcement of each module, by Key.
|
|
||||||
const KeptBucket = "mesh-controller_states-announced"
|
|
||||||
|
|
||||||
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||||
|
|
||||||
// Key is module's key in KeptBucket: the module itself, which must be one plain name, since a module's name is part of
|
// Key is module's key where the last announcement of each module is kept: the module itself, which must be one plain name, since a module's name is part of
|
||||||
// its buckets' names (catalogue.StateProblems).
|
// its buckets' names (the indexer's judge, novox/hq ADR 0578 §1).
|
||||||
func Key(module string) (string, error) {
|
func Key(module string) (string, error) {
|
||||||
if !name.MatchString(module) {
|
if !name.MatchString(module) {
|
||||||
return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module)
|
return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module)
|
||||||
@@ -46,6 +43,10 @@ func Key(module string) (string, error) {
|
|||||||
type State struct {
|
type State struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Readable bool `json:"readable,omitempty"`
|
Readable bool `json:"readable,omitempty"`
|
||||||
|
// Readers, when named, are the only modules that may read the state (novox/hq ADR 0547 clause 9, issue 1945): a
|
||||||
|
// state readable by some and not all, such as the vault's bus account hashes. Only ReadableBy grants them; Readable,
|
||||||
|
// which a composer that knows no readers asks, stays false for it, so such a composer grants nobody.
|
||||||
|
Readers []string `json:"readers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted
|
// States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted
|
||||||
@@ -67,6 +68,26 @@ func (s States) Keeps(state string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadableBy says whether the module announced the state readable by reader: readable, and reader among the readers
|
||||||
|
// when it names any.
|
||||||
|
func (s States) ReadableBy(state, reader string) bool {
|
||||||
|
for _, k := range s.States {
|
||||||
|
if k.Name != state {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(k.Readers) > 0 {
|
||||||
|
for _, r := range k.Readers {
|
||||||
|
if r == reader {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return k.Readable
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Readable says whether the module announced the state readable by others.
|
// Readable says whether the module announced the state readable by others.
|
||||||
func (s States) Readable(state string) bool {
|
func (s States) Readable(state string) bool {
|
||||||
for _, k := range s.States {
|
for _, k := range s.States {
|
||||||
@@ -93,6 +114,11 @@ func (s *States) check() error {
|
|||||||
return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name)
|
return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name)
|
||||||
}
|
}
|
||||||
seen[k.Name] = true
|
seen[k.Name] = true
|
||||||
|
for _, r := range k.Readers {
|
||||||
|
if !name.MatchString(r) {
|
||||||
|
return fmt.Errorf("announce: %s names %q as a reader of %s, and a reader is a module: lower-case letters, digits and hyphens", s.Module, r, k.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import (
|
|||||||
// own event subject, in a versioned value the controller keeps by module.
|
// own event subject, in a versioned value the controller keeps by module.
|
||||||
func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
|
func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
|
||||||
data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{
|
data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{
|
||||||
{Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"}}})
|
{Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"},
|
||||||
|
{Name: "hashes", Readers: []string{"mesh-grants"}}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -24,21 +25,23 @@ func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
|
|||||||
for _, s := range back.States {
|
for _, s := range back.States {
|
||||||
names = append(names, s.Name)
|
names = append(names, s.Name)
|
||||||
}
|
}
|
||||||
if strings.Join(names, ",") != "assignments,own,plans-building" {
|
if strings.Join(names, ",") != "assignments,hashes,own,plans-building" {
|
||||||
t.Fatalf("the states are not sorted: %v", names)
|
t.Fatalf("the states are not sorted: %v", names)
|
||||||
}
|
}
|
||||||
if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") {
|
if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") {
|
||||||
t.Fatal("Readable does not say what was announced readable")
|
t.Fatal("Readable does not say what was announced readable")
|
||||||
}
|
}
|
||||||
|
// Named readers (ADR 0547 clause 9): readable by them alone, and not readable to a composer that knows no readers.
|
||||||
|
if back.Readable("hashes") || !back.ReadableBy("hashes", "mesh-grants") || back.ReadableBy("hashes", "spy") ||
|
||||||
|
!back.ReadableBy("plans-building", "spy") || back.ReadableBy("own", "spy") {
|
||||||
|
t.Fatal("ReadableBy does not say who was named")
|
||||||
|
}
|
||||||
if !back.Keeps("own") || back.Keeps("absent") {
|
if !back.Keeps("own") || back.Keeps("absent") {
|
||||||
t.Fatal("Keeps does not say what was announced")
|
t.Fatal("Keeps does not say what was announced")
|
||||||
}
|
}
|
||||||
if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" {
|
if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" {
|
||||||
t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll)
|
t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll)
|
||||||
}
|
}
|
||||||
if KeptBucket != "mesh-controller_states-announced" {
|
|
||||||
t.Fatalf("the kept bucket is %q", KeptBucket)
|
|
||||||
}
|
|
||||||
if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" {
|
if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" {
|
||||||
t.Fatalf("Key = %q, %v", k, err)
|
t.Fatalf("Key = %q, %v", k, err)
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,7 @@ func TestAnAnnouncementRefusesWhatNoBucketCouldBe(t *testing.T) {
|
|||||||
{States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`},
|
{States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`},
|
||||||
{States{Module: "a", States: []State{{Name: ""}}}, `""`},
|
{States{Module: "a", States: []State{{Name: ""}}}, `""`},
|
||||||
{States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"},
|
{States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"},
|
||||||
|
{States{Module: "a", States: []State{{Name: "x", Readers: []string{"b.c"}}}}, `"b.c"`},
|
||||||
} {
|
} {
|
||||||
if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) {
|
if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||||
t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says)
|
t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says)
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
// Package asker is a module's client for asking a person (novox/hq ADR 0259 §10): it publishes an ask under
|
|
||||||
// the module's own name, cancels it, reads its state from the router's record, and hands the module each
|
|
||||||
// warrant once — only one for an ask it made, for an option it offered, at that option's level, before it
|
|
||||||
// expired. The module then acts with its own grants.
|
|
||||||
//
|
|
||||||
// It keeps what it asked, and which warrants it acted on, in a state of the module's own (declared in its
|
|
||||||
// manifest, by default `asked`): the ask is written before it is published, and a warrant is marked acted on
|
|
||||||
// by compare-and-set before the module's handler runs, so a redelivery, or a second instance, acts once.
|
|
||||||
package asker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client asks as one module.
|
|
||||||
type Client struct {
|
|
||||||
// Module is the asking module's name: the one the bus grants its asks under.
|
|
||||||
Module string
|
|
||||||
// State is the module's own state the asks are kept in.
|
|
||||||
State Store
|
|
||||||
// Now is the clock; nil is time.Now.
|
|
||||||
Now func() time.Time
|
|
||||||
// Publish, Record and Subscribe reach the bus; nil is the stdio SDK's, through the module's runtime.
|
|
||||||
Publish func(subject string, body any, id string) error
|
|
||||||
Record func(key string) (*stdio.StateEntry, error)
|
|
||||||
Subscribe func(pattern string, handle func(stdio.Envelope) error) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store is where the asks are kept: stdio.State(name) is one.
|
|
||||||
type Store interface {
|
|
||||||
Get(key string) (*stdio.StateEntry, error)
|
|
||||||
Create(key string, value any) (uint64, error)
|
|
||||||
Update(key string, value any, revision uint64) (uint64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New is a client for the module the runtime serves, keeping its asks in the state named.
|
|
||||||
func New(state string) *Client {
|
|
||||||
return &Client{Module: os.Getenv("MESH_MODULE"), State: stdio.State(state)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) now() time.Time {
|
|
||||||
if c.Now != nil {
|
|
||||||
return c.Now()
|
|
||||||
}
|
|
||||||
return time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kept is one ask as the module keeps it.
|
|
||||||
type Kept struct {
|
|
||||||
Ask asks.Ask `json:"ask"`
|
|
||||||
Asked time.Time `json:"asked"`
|
|
||||||
Acted bool `json:"acted,omitempty"`
|
|
||||||
Ended asks.Outcome `json:"ended,omitempty"`
|
|
||||||
Answer *asks.Warrant `json:"answer,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func key(id string) string { return "ask." + id }
|
|
||||||
|
|
||||||
// Ask checks an ask, keeps it, and publishes it under the module's own name. An ask with an id already kept
|
|
||||||
// is refused: a new question is a new id.
|
|
||||||
func (c *Client) Ask(a asks.Ask) error {
|
|
||||||
if err := a.Check(c.now()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := c.State.Create(key(a.ID), Kept{Ask: a, Asked: c.now()}); err != nil {
|
|
||||||
if errors.Is(err, stdio.ErrChanged) {
|
|
||||||
return fmt.Errorf("an ask %s was already made: a new question is a new id", a.ID)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.publish(asks.AskSubject(c.Module), a, a.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel takes an ask back: the router ends it and edits every copy.
|
|
||||||
func (c *Client) Cancel(id string) error {
|
|
||||||
return c.publish(asks.CancelSubject(c.Module), map[string]string{"id": id}, "cancel."+id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) publish(subject string, body any, id string) error {
|
|
||||||
if c.Publish != nil {
|
|
||||||
return c.Publish(subject, body, id)
|
|
||||||
}
|
|
||||||
return stdio.SeatPublish(subject, body, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where is an ask's state as the router keeps it: its state and its warrant, if any — read under the
|
|
||||||
// module's own name and no other's.
|
|
||||||
func (c *Client) Where(id string) (state string, w *asks.Warrant, err error) {
|
|
||||||
read := c.Record
|
|
||||||
if read == nil {
|
|
||||||
read = func(k string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", k) }
|
|
||||||
}
|
|
||||||
e, err := read(id)
|
|
||||||
if err != nil || e == nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
var rec struct {
|
|
||||||
State string `json:"state"`
|
|
||||||
Warrant *asks.Warrant `json:"warrant"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(e.Value, &rec); err != nil {
|
|
||||||
return "", nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err)
|
|
||||||
}
|
|
||||||
return rec.State, rec.Warrant, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handled is what came of a warrant: acted on, refused (and why), or already done.
|
|
||||||
type Handled string
|
|
||||||
|
|
||||||
const (
|
|
||||||
Acted Handled = "acted"
|
|
||||||
Refused Handled = "refused"
|
|
||||||
Done Handled = "already acted on"
|
|
||||||
Recorded Handled = "ended without a choice"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Take hands act a warrant once, after holding it to the ask it answers. act performs the option with the
|
|
||||||
// module's own grants; its error is said and the warrant stays acted on (a person's decision is not
|
|
||||||
// performed twice; the module says the failure in its own record).
|
|
||||||
func (c *Client) Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (Handled, error) {
|
|
||||||
if w.Asker != c.Module {
|
|
||||||
return Refused, fmt.Errorf("a warrant for %s reached %s", w.Asker, c.Module)
|
|
||||||
}
|
|
||||||
e, err := c.State.Get(key(w.Ask))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if e == nil {
|
|
||||||
return Refused, fmt.Errorf("%s holds no ask %s", c.Module, w.Ask)
|
|
||||||
}
|
|
||||||
var k Kept
|
|
||||||
if err := json.Unmarshal(e.Value, &k); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if k.Acted || k.Ended != "" {
|
|
||||||
return Done, nil
|
|
||||||
}
|
|
||||||
if w.Outcome != asks.OutcomeChosen {
|
|
||||||
k.Ended = w.Outcome
|
|
||||||
k.Answer = &w
|
|
||||||
if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil && !errors.Is(err, stdio.ErrChanged) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return Recorded, nil
|
|
||||||
}
|
|
||||||
o, err := w.For(c.Module, k.Ask)
|
|
||||||
if err != nil {
|
|
||||||
return Refused, err
|
|
||||||
}
|
|
||||||
// Marked before acting: of two deliveries, or two instances, only the one whose write stands acts.
|
|
||||||
k.Acted, k.Ended, k.Answer = true, asks.OutcomeChosen, &w
|
|
||||||
if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil {
|
|
||||||
if errors.Is(err, stdio.ErrChanged) {
|
|
||||||
return Done, nil
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return Acted, act(o, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen hands every warrant for this module to Take, with act. It asks the runtime for the module's
|
|
||||||
// events matching the router's `decided` subject for this module.
|
|
||||||
func (c *Client) Listen(act func(asks.Option, asks.Warrant) error, said func(Handled, asks.Warrant, error)) error {
|
|
||||||
subscribe := c.Subscribe
|
|
||||||
if subscribe == nil {
|
|
||||||
subscribe = stdio.Subscribe
|
|
||||||
}
|
|
||||||
return subscribe(asks.Seat+".decided."+c.Module, func(e stdio.Envelope) error {
|
|
||||||
if !strings.HasPrefix(e.Key, asks.Seat+".decided.") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var w asks.Warrant
|
|
||||||
if err := json.Unmarshal(e.Body, &w); err != nil {
|
|
||||||
if said != nil {
|
|
||||||
said(Refused, w, fmt.Errorf("a warrant could not be read: %w", err))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
h, err := c.Take(w, act)
|
|
||||||
if h == "" && err != nil {
|
|
||||||
return err // the state could not be reached: offered again
|
|
||||||
}
|
|
||||||
if said != nil {
|
|
||||||
said(h, w, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package asker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mem struct {
|
|
||||||
vals map[string]json.RawMessage
|
|
||||||
revs map[string]uint64
|
|
||||||
n uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mem) Get(k string) (*stdio.StateEntry, error) {
|
|
||||||
v, ok := m.vals[k]
|
|
||||||
if !ok {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil
|
|
||||||
}
|
|
||||||
func (m *mem) Create(k string, v any) (uint64, error) {
|
|
||||||
if _, ok := m.vals[k]; ok {
|
|
||||||
return 0, stdio.ErrChanged
|
|
||||||
}
|
|
||||||
return m.put(k, v)
|
|
||||||
}
|
|
||||||
func (m *mem) Update(k string, v any, rev uint64) (uint64, error) {
|
|
||||||
if rev == 0 || m.revs[k] != rev {
|
|
||||||
return 0, stdio.ErrChanged
|
|
||||||
}
|
|
||||||
return m.put(k, v)
|
|
||||||
}
|
|
||||||
func (m *mem) put(k string, v any) (uint64, error) {
|
|
||||||
raw, _ := json.Marshal(v)
|
|
||||||
m.n++
|
|
||||||
m.vals[k], m.revs[k] = raw, m.n
|
|
||||||
return m.n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
func client() (*Client, map[string][]string) {
|
|
||||||
sent := map[string][]string{}
|
|
||||||
c := &Client{Module: "mesh-delivery", State: &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}},
|
|
||||||
Now: func() time.Time { return now },
|
|
||||||
Publish: func(subject string, body any, id string) error {
|
|
||||||
sent[subject] = append(sent[subject], id)
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
return c, sent
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseHQ is the act the ask's one option binds.
|
|
||||||
var releaseHQ = map[string]string{"verb": "release", "delivery": "hq"}
|
|
||||||
|
|
||||||
func held() asks.Ask {
|
|
||||||
binds, _ := asks.ActDigest(releaseHQ)
|
|
||||||
return asks.Ask{ID: "d-1", Headline: "Delivery of hq held", Explanation: "Needs you: release it, or stop it.",
|
|
||||||
Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held",
|
|
||||||
Options: []asks.Option{{ID: "release", Label: "Release", Does: "it goes on", Level: asks.Approve, Binds: binds}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func warrant() asks.Warrant {
|
|
||||||
return asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: asks.OutcomeChosen, Option: "release",
|
|
||||||
Label: "Release", Level: asks.Approve, At: now.Add(time.Minute), AskDigest: held().Digest(),
|
|
||||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAskIsKeptThenPublishedUnderTheModulesOwnName(t *testing.T) {
|
|
||||||
c, sent := client()
|
|
||||||
if err := c.Ask(held()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := sent[asks.AskSubject("mesh-delivery")]; len(got) != 1 || got[0] != "d-1" {
|
|
||||||
t.Fatalf("%v", sent)
|
|
||||||
}
|
|
||||||
if err := c.Ask(held()); err == nil || !strings.Contains(err.Error(), "already made") {
|
|
||||||
t.Errorf("the same id was asked twice: %v", err)
|
|
||||||
}
|
|
||||||
bad := held()
|
|
||||||
bad.Who = "agent"
|
|
||||||
if err := c.Ask(bad); err == nil {
|
|
||||||
t.Error("an ask the contract refuses was published")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAWarrantIsActedOnOnceAndOnlyForItsOwnAsk(t *testing.T) {
|
|
||||||
c, _ := client()
|
|
||||||
_ = c.Ask(held())
|
|
||||||
acted := 0
|
|
||||||
act := func(o asks.Option, _ asks.Warrant) error {
|
|
||||||
if err := o.Performs(releaseHQ); err != nil {
|
|
||||||
t.Errorf("the bound act: %v", err)
|
|
||||||
}
|
|
||||||
acted++
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if h, err := c.Take(warrant(), act); h != Acted || err != nil || acted != 1 {
|
|
||||||
t.Fatalf("%s %v %d", h, err, acted)
|
|
||||||
}
|
|
||||||
if h, _ := c.Take(warrant(), act); h != Done || acted != 1 {
|
|
||||||
t.Errorf("a redelivery was acted on: %s", h)
|
|
||||||
}
|
|
||||||
for why, change := range map[string]func(*asks.Warrant){
|
|
||||||
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
|
|
||||||
"an ask not made": func(w *asks.Warrant) { w.Ask = "d-9" },
|
|
||||||
"another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge },
|
|
||||||
"an option not offered": func(w *asks.Warrant) { w.Option = "delete" },
|
|
||||||
"after its expiry": func(w *asks.Warrant) { w.At = now.Add(2 * time.Hour) },
|
|
||||||
"another ask's digest": func(w *asks.Warrant) { w.AskDigest = "sha256:00" },
|
|
||||||
} {
|
|
||||||
c2, _ := client()
|
|
||||||
_ = c2.Ask(held())
|
|
||||||
w := warrant()
|
|
||||||
change(&w)
|
|
||||||
if h, _ := c2.Take(w, act); h == Acted {
|
|
||||||
t.Errorf("a warrant from %s was acted on", why)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if acted != 1 {
|
|
||||||
t.Errorf("acted %d times", acted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAskEndedWithoutAChoiceIsRecordedAndNothingDone(t *testing.T) {
|
|
||||||
c, _ := client()
|
|
||||||
_ = c.Ask(held())
|
|
||||||
w := warrant()
|
|
||||||
w.Outcome, w.Option, w.By = asks.OutcomeExpired, "", nil
|
|
||||||
if h, err := c.Take(w, func(asks.Option, asks.Warrant) error { t.Fatal("acted"); return nil }); h != Recorded || err != nil {
|
|
||||||
t.Fatalf("%s %v", h, err)
|
|
||||||
}
|
|
||||||
if h, _ := c.Take(warrant(), func(asks.Option, asks.Warrant) error { t.Fatal("acted after it ended"); return nil }); h != Done {
|
|
||||||
t.Errorf("%s", h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-573
@@ -1,573 +0,0 @@
|
|||||||
// Package asks is the contract of asking a person and answering on a channel (novox/hq ADR 0259): the
|
|
||||||
// shapes an asker, the router and a channel exchange on the bus, and the subjects they travel on. No
|
|
||||||
// transport and no channel's service: an asker publishes an Ask under its own name and acts on the Warrant
|
|
||||||
// it hears; the router holds the ask, sends channels a Message, and judges the Choice a channel says; a
|
|
||||||
// channel shows a Message and says what was chosen and by whom, as its service authenticated it.
|
|
||||||
package asks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The seats (novox/hq ADR 0259 §3).
|
|
||||||
const (
|
|
||||||
// Seat is held by the router: an ask and its cancel are its accepts, a warrant its event, each named by
|
|
||||||
// the asker.
|
|
||||||
Seat = "operator-channel"
|
|
||||||
// ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind.
|
|
||||||
ChannelSeat = "channel"
|
|
||||||
// IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry
|
|
||||||
// the kind.
|
|
||||||
IntakeSeat = "intake"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AskSubject is where an asker publishes an ask, CancelSubject its cancel, and DecidedSubject where it
|
|
||||||
// hears the warrant, or the ask's end without one.
|
|
||||||
func AskSubject(asker string) string { return "mesh.seat." + Seat + ".accept.ask." + asker }
|
|
||||||
func CancelSubject(asker string) string { return "mesh.seat." + Seat + ".accept.cancel." + asker }
|
|
||||||
func DecidedSubject(asker string) string { return "mesh.seat." + Seat + ".event.decided." + asker }
|
|
||||||
|
|
||||||
// The work a channel takes, on ChannelSubject.
|
|
||||||
const (
|
|
||||||
Show = "show" // a message offering answers
|
|
||||||
Edit = "edit" // a message shown before, replaced
|
|
||||||
Send = "send" // a message offering nothing
|
|
||||||
)
|
|
||||||
|
|
||||||
// ChannelSubject is where the router sends a channel of a kind its work.
|
|
||||||
func ChannelSubject(verb, kind string) string {
|
|
||||||
return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind
|
|
||||||
}
|
|
||||||
|
|
||||||
// What a channel says, on IntakeSubject.
|
|
||||||
const (
|
|
||||||
Chosen = "choice" // a button tapped
|
|
||||||
Link = "link" // somebody asked to be linked as the operator
|
|
||||||
)
|
|
||||||
|
|
||||||
// IntakeSubject is where a channel of a kind says what arrived.
|
|
||||||
func IntakeSubject(what, kind string) string {
|
|
||||||
return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind
|
|
||||||
}
|
|
||||||
|
|
||||||
// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept.
|
|
||||||
const CodeProof = "code"
|
|
||||||
|
|
||||||
// ProofSubject is where a channel of a kind asks a proof.
|
|
||||||
func ProofSubject(verb, kind string) string {
|
|
||||||
return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Level is how much proof an option's answer needs (novox/hq ADR 0234 §8, the glossary's assurance level).
|
|
||||||
type Level string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Acknowledge performs only what any granted principal may already do: silencing, details. No proof.
|
|
||||||
Acknowledge Level = "acknowledge"
|
|
||||||
// Approve needs one proof: a verified sender (a linked account, linked an hour or more) or a code.
|
|
||||||
Approve Level = "approve"
|
|
||||||
// Destroy needs two proofs, one of them a code.
|
|
||||||
Destroy Level = "destroy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rank orders the levels; an unknown level ranks above every known one, so it is never taken as less.
|
|
||||||
func (l Level) Rank() int {
|
|
||||||
switch l {
|
|
||||||
case Acknowledge:
|
|
||||||
return 0
|
|
||||||
case Approve:
|
|
||||||
return 1
|
|
||||||
case Destroy:
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
|
|
||||||
// Operator is the one role an ask may be answered by today.
|
|
||||||
const Operator = "operator"
|
|
||||||
|
|
||||||
// Option is one answer an ask offers: a label for the button, what it does in plain words, its level.
|
|
||||||
type Option struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Does string `json:"does"`
|
|
||||||
Level Level `json:"level"`
|
|
||||||
// Binds is the digest of exactly what the asker performs when this option is chosen — the verb, the
|
|
||||||
// machine and every argument — as ActDigest gives it. It travels in the ask, so the router's warrant,
|
|
||||||
// which names the ask's digest, names it too: a warrant then authorises that act and no other, and an
|
|
||||||
// asker whose record of the act changed after it asked finds the digests differ and does nothing.
|
|
||||||
// Required on an option above acknowledge.
|
|
||||||
Binds string `json:"binds,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each
|
|
||||||
// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and
|
|
||||||
// values alone, never over how a language happens to encode a struct.
|
|
||||||
type Act map[string]string
|
|
||||||
|
|
||||||
// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's canonical encoding (canonical),
|
|
||||||
// written "sha256:<hex>". The same names and values give the same digest in any language, whatever order
|
|
||||||
// they were set in; a field renamed, added or emptied gives another.
|
|
||||||
func ActDigest(act Act) (string, error) {
|
|
||||||
if len(act) == 0 {
|
|
||||||
return "", fmt.Errorf("the act cannot be digested: it names nothing")
|
|
||||||
}
|
|
||||||
keys := make([]string, 0, len(act))
|
|
||||||
for k := range act {
|
|
||||||
if k == "" {
|
|
||||||
return "", fmt.Errorf("the act cannot be digested: a field has no name")
|
|
||||||
}
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("novox.act.v1\n")
|
|
||||||
for _, k := range keys {
|
|
||||||
canonical(&b, k)
|
|
||||||
canonical(&b, act[k])
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(b.String()))
|
|
||||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read
|
|
||||||
// as another's end or start, so two different sequences of values never encode the same.
|
|
||||||
func canonical(b *strings.Builder, v string) {
|
|
||||||
b.WriteString(strconv.Itoa(len(v)))
|
|
||||||
b.WriteByte(':')
|
|
||||||
b.WriteString(v)
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each
|
|
||||||
// binds, who answers, and its expiry. The router puts it in the warrant (Warrant.AskDigest), and an asker
|
|
||||||
// acts only on a warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the
|
|
||||||
// person was shown it, and nothing published under the same id before or after.
|
|
||||||
//
|
|
||||||
// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC
|
|
||||||
// RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask
|
|
||||||
// later changes no digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow
|
|
||||||
// the options only when the ask gives either: an ask without them digests as it did before they existed, so
|
|
||||||
// a router and an asker of different builds still agree on every such ask.
|
|
||||||
func (a Ask) Digest() string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("novox.ask.v1\n")
|
|
||||||
for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who,
|
|
||||||
a.Expires.UTC().Format(time.RFC3339Nano), a.OnExpiry, a.About, strconv.FormatBool(a.Urgent),
|
|
||||||
strconv.Itoa(len(a.Options))} {
|
|
||||||
canonical(&b, v)
|
|
||||||
}
|
|
||||||
for _, o := range a.Options {
|
|
||||||
for _, v := range []string{o.ID, o.Label, o.Does, string(o.Level), o.Binds} {
|
|
||||||
canonical(&b, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever
|
|
||||||
// it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes —
|
|
||||||
// none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks
|
|
||||||
// encode the same (the review of mesh-sdk #25). An ask with none of them digests as before they existed, and
|
|
||||||
// one without WholeDetails as before it existed.
|
|
||||||
if a.Whole != "" || a.Details != "" || a.WholeDetails != "" {
|
|
||||||
canonical(&b, a.Whole)
|
|
||||||
canonical(&b, a.Details)
|
|
||||||
}
|
|
||||||
if a.WholeDetails != "" {
|
|
||||||
canonical(&b, a.WholeDetails)
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(b.String()))
|
|
||||||
return "sha256:" + hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask is a request for a person's word (novox/hq ADR 0259 §4).
|
|
||||||
type Ask struct {
|
|
||||||
// ID is the asker's own, unique to it.
|
|
||||||
ID string `json:"id"`
|
|
||||||
// Headline names the thing and what is wrong, in a few plain words; Explanation is what happened and
|
|
||||||
// what it means. Both are held to the plain rule and the content rule by the router.
|
|
||||||
Headline string `json:"headline"`
|
|
||||||
Explanation string `json:"explanation"`
|
|
||||||
Options []Option `json:"options"`
|
|
||||||
// Who may answer: Operator.
|
|
||||||
Who string `json:"who"`
|
|
||||||
// Expires is when the ask ends unanswered; OnExpiry is what the asker then does, in words the person
|
|
||||||
// is shown ("the delivery stays held"). An ask that authorises never defaults.
|
|
||||||
Expires time.Time `json:"expires"`
|
|
||||||
OnExpiry string `json:"on-expiry"`
|
|
||||||
// About is what the ask is about (a condition's key): a newer ask about it replaces the older.
|
|
||||||
About string `json:"about,omitempty"`
|
|
||||||
Urgent bool `json:"urgent,omitempty"`
|
|
||||||
// Whole is the explanation with its exact values whole, shown in place of Explanation on a channel kind
|
|
||||||
// that proves who answers (verified-sender) and carries the ask's answers (novox/hq issue 383): what the
|
|
||||||
// person approves — a mount point, a share, a private address — must be readable where they approve it.
|
|
||||||
// The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left;
|
|
||||||
// Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere.
|
|
||||||
Whole string `json:"whole,omitempty"`
|
|
||||||
// Details are the ask's secondary words, line by line under the content rule: who proposed it, a
|
|
||||||
// fingerprint, how to read it whole. The router shows them in the note of the ask's own message
|
|
||||||
// (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq
|
|
||||||
// issue 639).
|
|
||||||
Details string `json:"details,omitempty"`
|
|
||||||
// WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown
|
|
||||||
// in place of the explanation (a kind that proves who answers, given the ask's answers): every value an
|
|
||||||
// approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like
|
|
||||||
// Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty,
|
|
||||||
// Details are shown everywhere.
|
|
||||||
WholeDetails string `json:"whole-details,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary
|
|
||||||
// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at
|
|
||||||
// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when,
|
|
||||||
// the fingerprint and how to read it whole are Details, never the explanation.
|
|
||||||
const (
|
|
||||||
SummaryLength = 300
|
|
||||||
MostPoints = 3
|
|
||||||
PointLength = 160
|
|
||||||
)
|
|
||||||
|
|
||||||
// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than
|
|
||||||
// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not
|
|
||||||
// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that
|
|
||||||
// does not hold to it.
|
|
||||||
func Glance(explanation string) error {
|
|
||||||
var lines []string
|
|
||||||
for _, l := range strings.Split(explanation, "\n") {
|
|
||||||
if strings.TrimSpace(l) != "" {
|
|
||||||
lines = append(lines, strings.TrimSpace(l))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
switch {
|
|
||||||
case len(lines) == 0:
|
|
||||||
problems = append(problems, "it has no summary")
|
|
||||||
case len([]rune(lines[0])) > SummaryLength:
|
|
||||||
problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength))
|
|
||||||
}
|
|
||||||
if len(lines) > 1+MostPoints {
|
|
||||||
problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints))
|
|
||||||
}
|
|
||||||
for i, l := range lines {
|
|
||||||
if i > 0 && len([]rune(l)) > PointLength {
|
|
||||||
problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4).
|
|
||||||
const (
|
|
||||||
MostOptions = 4
|
|
||||||
MostOpen = 3
|
|
||||||
ApproveLasts = 24 * time.Hour
|
|
||||||
DestroyLasts = 10 * time.Minute
|
|
||||||
HeadlineLength = 60
|
|
||||||
LabelLength = 24
|
|
||||||
)
|
|
||||||
|
|
||||||
var usableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
|
|
||||||
|
|
||||||
// UsableID says whether a name can be an ask's or an option's id: a key and a subject token both.
|
|
||||||
func UsableID(id string) bool { return usableID.MatchString(id) }
|
|
||||||
|
|
||||||
// Highest is the highest level among the ask's options.
|
|
||||||
func (a Ask) Highest() Level {
|
|
||||||
high := Acknowledge
|
|
||||||
for _, o := range a.Options {
|
|
||||||
if o.Level.Rank() > high.Rank() {
|
|
||||||
high = o.Level
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return high
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option is the option of this id, or false.
|
|
||||||
func (a Ask) Option(id string) (Option, bool) {
|
|
||||||
for _, o := range a.Options {
|
|
||||||
if o.ID == id {
|
|
||||||
return o, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Option{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check is what an ask is held to before anything is shown: every refusal, in words its asker can act on.
|
|
||||||
func (a Ask) Check(now time.Time) error {
|
|
||||||
var problems []string
|
|
||||||
say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) }
|
|
||||||
if !UsableID(a.ID) {
|
|
||||||
say("its id %q is not letters, digits, - and _, at most 64", a.ID)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(a.Headline) == "" || len([]rune(a.Headline)) > HeadlineLength {
|
|
||||||
say("its headline is empty or longer than %d characters", HeadlineLength)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(a.Explanation) == "" {
|
|
||||||
say("it explains nothing")
|
|
||||||
}
|
|
||||||
if a.Who != Operator {
|
|
||||||
say("it is answered by %q, and only the operator answers today", a.Who)
|
|
||||||
}
|
|
||||||
if len(a.Options) == 0 || len(a.Options) > MostOptions {
|
|
||||||
say("it offers %d options, and an ask offers one to %d", len(a.Options), MostOptions)
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, o := range a.Options {
|
|
||||||
switch {
|
|
||||||
case !UsableID(o.ID):
|
|
||||||
say("an option's id %q is not letters, digits, - and _", o.ID)
|
|
||||||
case seen[o.ID]:
|
|
||||||
say("the option %s is offered twice", o.ID)
|
|
||||||
}
|
|
||||||
seen[o.ID] = true
|
|
||||||
if strings.TrimSpace(o.Label) == "" || len([]rune(o.Label)) > LabelLength {
|
|
||||||
say("the option %s's label is empty or longer than %d characters", o.ID, LabelLength)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(o.Does) == "" {
|
|
||||||
say("the option %s does not say what it does", o.ID)
|
|
||||||
}
|
|
||||||
if o.Level.Rank() > Destroy.Rank() {
|
|
||||||
say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level)
|
|
||||||
}
|
|
||||||
if o.Level != Acknowledge && !strings.HasPrefix(o.Binds, "sha256:") {
|
|
||||||
say("the option %s authorises and does not bind what it performs (its binds is not an ActDigest)", o.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !a.Expires.After(now) {
|
|
||||||
say("it expires before it is asked")
|
|
||||||
}
|
|
||||||
switch a.Highest() {
|
|
||||||
case Approve:
|
|
||||||
if a.Expires.After(now.Add(ApproveLasts)) {
|
|
||||||
say("an ask that approves lasts at most %s", ApproveLasts)
|
|
||||||
}
|
|
||||||
case Destroy:
|
|
||||||
if a.Expires.After(now.Add(DestroyLasts)) {
|
|
||||||
say("an ask that destroys lasts at most %s", DestroyLasts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if a.Highest() != Acknowledge && strings.TrimSpace(a.OnExpiry) == "" {
|
|
||||||
say("it does not say what happens when nobody answers, and an ask that authorises never defaults")
|
|
||||||
}
|
|
||||||
if a.About != "" && strings.ContainsAny(a.About, " \n") {
|
|
||||||
say("what it is about is a key, without spaces")
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
return errors.New("the ask is refused: " + strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outcome is how an ask ended.
|
|
||||||
type Outcome string
|
|
||||||
|
|
||||||
const (
|
|
||||||
OutcomeChosen Outcome = "chosen" // a person chose an option: a warrant
|
|
||||||
OutcomeExpired Outcome = "expired" // nobody answered in time
|
|
||||||
OutcomeCancelled Outcome = "cancelled" // its asker took it back
|
|
||||||
OutcomeReplaced Outcome = "replaced" // a newer ask about the same thing replaced it
|
|
||||||
OutcomeRefused Outcome = "refused" // it was never shown: Words says why
|
|
||||||
)
|
|
||||||
|
|
||||||
// Person is who chose, as the router verified them.
|
|
||||||
type Person struct {
|
|
||||||
// Who is the role: Operator.
|
|
||||||
Who string `json:"who"`
|
|
||||||
// Kind is the channel kind they answered on, Identity their account on that service, Display the name
|
|
||||||
// the service shows, and Verified how the router knew it was them.
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Identity string `json:"identity"`
|
|
||||||
Display string `json:"display,omitempty"`
|
|
||||||
Verified string `json:"verified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warrant is the router's record that a person chose one option of one ask, or the ask's end without one
|
|
||||||
// (novox/hq ADR 0259 §6). It carries no secret.
|
|
||||||
type Warrant struct {
|
|
||||||
Ask string `json:"ask"`
|
|
||||||
Asker string `json:"asker"`
|
|
||||||
About string `json:"about,omitempty"`
|
|
||||||
Outcome Outcome `json:"outcome"`
|
|
||||||
// Option, Label and Level are the option chosen; By who chose it, Channel the module it came through,
|
|
||||||
// Proofs which proofs were present (P1, P2, P3).
|
|
||||||
Option string `json:"option,omitempty"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
Level Level `json:"level,omitempty"`
|
|
||||||
By *Person `json:"by,omitempty"`
|
|
||||||
Channel string `json:"channel,omitempty"`
|
|
||||||
Proofs []string `json:"proofs,omitempty"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
// AskDigest is the digest of the ask as the router took it (Ask.Digest): the warrant answers that ask
|
|
||||||
// alone, with the options it bound.
|
|
||||||
AskDigest string `json:"ask-digest,omitempty"`
|
|
||||||
// Words are why an ask ended without a choice, or what refused it.
|
|
||||||
Words string `json:"words,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Says is the warrant in the words an asker records with its act: "the operator, via telegram (user id
|
|
||||||
// verified), chose Release".
|
|
||||||
func (w Warrant) Says() string {
|
|
||||||
if w.Outcome != OutcomeChosen || w.By == nil {
|
|
||||||
return fmt.Sprintf("no person chose: the ask %s %s", w.Ask, w.Outcome)
|
|
||||||
}
|
|
||||||
via := w.By.Kind
|
|
||||||
if w.By.Verified != "" {
|
|
||||||
via += " (" + w.By.Verified + ")"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the
|
|
||||||
// same words, options and binds), a choice, an option the ask offered, at that option's level, before the
|
|
||||||
// ask expired. An asker acts on nothing else, and then performs only what the option's Binds names.
|
|
||||||
func (w Warrant) For(asker string, a Ask) (Option, error) {
|
|
||||||
if w.Asker != asker || w.Ask != a.ID {
|
|
||||||
return Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID)
|
|
||||||
}
|
|
||||||
if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who {
|
|
||||||
return Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome)
|
|
||||||
}
|
|
||||||
if d := a.Digest(); w.AskDigest != d {
|
|
||||||
return Option{}, fmt.Errorf("the warrant answers an ask whose digest is %q, and the ask %s kept here is %s: "+
|
|
||||||
"it was not the ask the person was shown", w.AskDigest, a.ID, d)
|
|
||||||
}
|
|
||||||
o, offered := a.Option(w.Option)
|
|
||||||
if !offered {
|
|
||||||
return Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option)
|
|
||||||
}
|
|
||||||
if w.Level != o.Level {
|
|
||||||
return Option{}, fmt.Errorf("the option %s is %s, and the warrant says %s", o.ID, o.Level, w.Level)
|
|
||||||
}
|
|
||||||
// A choice made after the ask expired is no answer to it, whatever the router said. An ask that says no
|
|
||||||
// expiry, or a warrant that says no time, is no answer either: neither can be shown to be in time (the
|
|
||||||
// confirmation review of 2026-10-09).
|
|
||||||
if a.Expires.IsZero() {
|
|
||||||
return Option{}, fmt.Errorf("the ask %s says no expiry, so no answer to it can be in time", a.ID)
|
|
||||||
}
|
|
||||||
if w.At.IsZero() {
|
|
||||||
return Option{}, fmt.Errorf("the warrant for the ask %s says no time it was given, so it cannot be shown to be in time", a.ID)
|
|
||||||
}
|
|
||||||
if w.At.After(a.Expires) {
|
|
||||||
return Option{}, fmt.Errorf("the warrant was given at %s, after the ask %s expired at %s",
|
|
||||||
w.At.UTC().Format(time.RFC3339), a.ID, a.Expires.UTC().Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
return o, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Performs checks that the act an asker is about to perform is the one the chosen option bound when it
|
|
||||||
// asked: the act's digest equals the option's Binds. An acknowledge option that bound nothing passes.
|
|
||||||
func (o Option) Performs(act Act) error {
|
|
||||||
if o.Binds == "" && o.Level == Acknowledge {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
d, err := ActDigest(act)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if d != o.Binds {
|
|
||||||
return fmt.Errorf("the option %s bound %s, and the act about to be performed is %s: nothing is done", o.ID, o.Binds, d)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Button is one answer a channel offers: its label and the router's one-time ticket for it.
|
|
||||||
type Button struct {
|
|
||||||
Label string `json:"label"`
|
|
||||||
Ticket string `json:"ticket"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message is the work a channel takes: shown with buttons (Show), shown again in place (Edit), or said
|
|
||||||
// (Send). Handle is the router's name for it, the same across a show and its edits; the channel keeps
|
|
||||||
// which of its own messages that is. The words are the router's, shown as given.
|
|
||||||
type Message struct {
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
Buttons []Button `json:"buttons,omitempty"`
|
|
||||||
// Note is the message's secondary words, after its body and its buttons: who asked and when, what each
|
|
||||||
// answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart
|
|
||||||
// where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's
|
|
||||||
// length cuts the body first and the note only when the title and the note alone do not fit, saying each
|
|
||||||
// cut (novox/hq ADR 0347 §5).
|
|
||||||
Note string `json:"note,omitempty"`
|
|
||||||
Urgent bool `json:"urgent,omitempty"`
|
|
||||||
Silent bool `json:"silent,omitempty"`
|
|
||||||
// Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made.
|
|
||||||
Reply string `json:"reply,omitempty"`
|
|
||||||
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where
|
|
||||||
// the channel sends what is not a reply. The router's word, from its list; empty when none is linked.
|
|
||||||
To string `json:"to,omitempty"`
|
|
||||||
// Secret says the words carry something shown once (a link's code): the channel shows it and keeps no
|
|
||||||
// copy of it — no state, no history of its own.
|
|
||||||
Secret bool `json:"secret,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sender is who a channel's service says sent something: the account, the name it shows, and whether the
|
|
||||||
// service authenticated it. The router alone judges whether that is the operator.
|
|
||||||
type Sender struct {
|
|
||||||
Identity string `json:"identity"`
|
|
||||||
Display string `json:"display,omitempty"`
|
|
||||||
Authenticated bool `json:"authenticated"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Failed is a message a channel could not deliver: its handle, why, and whether trying again could help.
|
|
||||||
type Failed struct {
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
Why string `json:"why"`
|
|
||||||
Permanent bool `json:"permanent,omitempty"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standing is what a channel says of itself, at least every five minutes and whenever it changes:
|
|
||||||
// whether it can send now, why not, and whether its edits notify nobody.
|
|
||||||
type Standing struct {
|
|
||||||
Ready bool `json:"ready"`
|
|
||||||
Why string `json:"why,omitempty"`
|
|
||||||
EditsSilently bool `json:"edits-silently,omitempty"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// What a channel says of its own delivery, on IntakeSubject.
|
|
||||||
const (
|
|
||||||
FailedWhat = "failed"
|
|
||||||
StandingWhat = "standing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Choice is a button chosen on a channel.
|
|
||||||
type Choice struct {
|
|
||||||
// ID is the channel's own for this arrival, unique, so the router acts on it once.
|
|
||||||
ID string `json:"id"`
|
|
||||||
Ticket string `json:"ticket"`
|
|
||||||
Handle string `json:"handle,omitempty"`
|
|
||||||
Sender Sender `json:"sender"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LinkAsked is somebody on a channel asking to be linked as the operator.
|
|
||||||
type LinkAsked struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Sender Sender `json:"sender"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code is a code a person typed on a channel, asked as a proof: never in an event, never kept.
|
|
||||||
type Code struct {
|
|
||||||
Sender Sender `json:"sender"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
Purpose string `json:"purpose"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProofAnswer is the router's answer to a proof: whether it was taken, and words to say to the person.
|
|
||||||
type ProofAnswer struct {
|
|
||||||
Accepted bool `json:"accepted"`
|
|
||||||
Words string `json:"words"`
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
package asks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
func held() Ask {
|
|
||||||
return Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.",
|
|
||||||
Who: Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held", About: "delivery.openrazer",
|
|
||||||
Options: []Option{
|
|
||||||
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: Approve, Binds: bind("release", "openrazer")},
|
|
||||||
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: Approve, Binds: bind("stop", "openrazer")},
|
|
||||||
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said for a week", Level: Acknowledge},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bind(verb, what string) string {
|
|
||||||
d, err := ActDigest(Act{"verb": verb, "delivery": what})
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestASoundAskPasses(t *testing.T) {
|
|
||||||
if err := held().Check(now); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) {
|
|
||||||
for want, change := range map[string]func(*Ask){
|
|
||||||
"is not letters": func(a *Ask) { a.ID = "a.b" },
|
|
||||||
"headline is empty": func(a *Ask) { a.Headline = "" },
|
|
||||||
"explains nothing": func(a *Ask) { a.Explanation = " " },
|
|
||||||
"only the operator answers": func(a *Ask) { a.Who = "agent" },
|
|
||||||
"offers 0 options": func(a *Ask) { a.Options = nil },
|
|
||||||
"offers 5 options": func(a *Ask) { a.Options = append(a.Options, a.Options[0], a.Options[1]) },
|
|
||||||
"is offered twice": func(a *Ask) { a.Options[1].ID = "release" },
|
|
||||||
"does not say what it does": func(a *Ask) { a.Options[0].Does = "" },
|
|
||||||
"none of acknowledge": func(a *Ask) { a.Options[0].Level = "admin" },
|
|
||||||
"expires before it is asked": func(a *Ask) { a.Expires = now },
|
|
||||||
"approves lasts at most": func(a *Ask) { a.Expires = now.Add(25 * time.Hour) },
|
|
||||||
"destroys lasts at most": func(a *Ask) { a.Options[0].Level = Destroy },
|
|
||||||
"never defaults": func(a *Ask) { a.OnExpiry = "" },
|
|
||||||
"is a key, without spaces": func(a *Ask) { a.About = "a b" },
|
|
||||||
"does not bind what it": func(a *Ask) { a.Options[0].Binds = "" },
|
|
||||||
} {
|
|
||||||
a := held()
|
|
||||||
change(&a)
|
|
||||||
if err := a.Check(now); err == nil || !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("want a refusal saying %q, got %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ack := held()
|
|
||||||
ack.Options = ack.Options[2:]
|
|
||||||
ack.OnExpiry = ""
|
|
||||||
ack.Expires = now.Add(7 * 24 * time.Hour)
|
|
||||||
if err := ack.Check(now); err != nil {
|
|
||||||
t.Errorf("an ask that only acknowledges needs no expiry words and may last a week: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) {
|
|
||||||
w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release",
|
|
||||||
Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
|
||||||
AskDigest: held().Digest(), At: now.Add(time.Minute)}
|
|
||||||
if o, err := w.For("mesh-delivery", held()); err != nil || o.ID != "release" {
|
|
||||||
t.Fatalf("a sound warrant was refused: %v", err)
|
|
||||||
}
|
|
||||||
if got := w.Says(); got != "the operator, via telegram (user id verified), chose Release" {
|
|
||||||
t.Errorf("the warrant says %q", got)
|
|
||||||
}
|
|
||||||
for why, change := range map[string]func(*Warrant){
|
|
||||||
"another asker": func(w *Warrant) { w.Asker = "mesh-controller" },
|
|
||||||
"another ask": func(w *Warrant) { w.Ask = "d-2" },
|
|
||||||
"no choice": func(w *Warrant) { w.Outcome = OutcomeExpired },
|
|
||||||
"nobody": func(w *Warrant) { w.By = nil },
|
|
||||||
"not offered": func(w *Warrant) { w.Option = "delete" },
|
|
||||||
"a lower level": func(w *Warrant) { w.Level = Acknowledge },
|
|
||||||
"another role": func(w *Warrant) { w.By = &Person{Who: "agent"} },
|
|
||||||
"no digest": func(w *Warrant) { w.AskDigest = "" },
|
|
||||||
"no time given": func(w *Warrant) { w.At = time.Time{} },
|
|
||||||
"after expiry": func(w *Warrant) { w.At = now.Add(2 * time.Hour) },
|
|
||||||
"another ask's digest": func(w *Warrant) {
|
|
||||||
other := held()
|
|
||||||
other.Options[0].Binds = bind("release", "another")
|
|
||||||
w.AskDigest = other.Digest()
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
c := w
|
|
||||||
by := *w.By
|
|
||||||
c.By = &by
|
|
||||||
change(&c)
|
|
||||||
if _, err := c.For("mesh-delivery", held()); err == nil {
|
|
||||||
t.Errorf("a warrant with %s was acted on", why)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheSubjectsNameTheirPublisher(t *testing.T) {
|
|
||||||
for got, want := range map[string]string{
|
|
||||||
AskSubject("mesh-delivery"): "mesh.seat.operator-channel.accept.ask.mesh-delivery",
|
|
||||||
DecidedSubject("mesh-delivery"): "mesh.seat.operator-channel.event.decided.mesh-delivery",
|
|
||||||
ChannelSubject(Show, "telegram"): "mesh.seat.channel.accept.show.telegram",
|
|
||||||
IntakeSubject(Chosen, "desktop"): "mesh.seat.intake.event.choice.desktop",
|
|
||||||
ProofSubject(CodeProof, "telegram"): "mesh.seat.intake.proof.code.telegram",
|
|
||||||
} {
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("%s, want %s", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A warrant answers one ask as it was shown, and authorises the act its option bound and no other
|
|
||||||
// (novox/hq ADR 0259 §6: the warrant is bound to the exact ask and its arguments).
|
|
||||||
func TestAWarrantAuthorisesOnlyTheActItsOptionBound(t *testing.T) {
|
|
||||||
a := held()
|
|
||||||
// The digest survives the trip through the bus: what the router decodes and digests is what was sent.
|
|
||||||
raw, err := json.Marshal(a)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var decoded Ask
|
|
||||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if decoded.Digest() != a.Digest() {
|
|
||||||
t.Fatalf("the ask's digest changed on the way: %s, %s", decoded.Digest(), a.Digest())
|
|
||||||
}
|
|
||||||
local := a
|
|
||||||
local.Expires = a.Expires.In(time.FixedZone("CEST", 2*3600))
|
|
||||||
if local.Digest() != a.Digest() {
|
|
||||||
t.Error("the same instant in another zone digests differently")
|
|
||||||
}
|
|
||||||
// Changing anything the person was shown, or what an option binds, changes the digest.
|
|
||||||
for what, change := range map[string]func(*Ask){
|
|
||||||
"the headline": func(a *Ask) { a.Headline += "!" },
|
|
||||||
"the explanation": func(a *Ask) { a.Explanation += " " },
|
|
||||||
"a label": func(a *Ask) { a.Options[0].Label = "Go" },
|
|
||||||
"what it binds": func(a *Ask) { a.Options[0].Binds = bind("release", "another") },
|
|
||||||
"the expiry": func(a *Ask) { a.Expires = a.Expires.Add(time.Second) },
|
|
||||||
"what it is about": func(a *Ask) { a.About = "delivery.other" },
|
|
||||||
} {
|
|
||||||
c := held()
|
|
||||||
change(&c)
|
|
||||||
if c.Digest() == a.Digest() {
|
|
||||||
t.Errorf("changing %s leaves the digest", what)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
o, _ := a.Option("release")
|
|
||||||
if err := o.Performs(Act{"verb": "release", "delivery": "openrazer"}); err != nil {
|
|
||||||
t.Errorf("the bound act was refused: %v", err)
|
|
||||||
}
|
|
||||||
if err := o.Performs(Act{"verb": "release", "delivery": "another"}); err == nil {
|
|
||||||
t.Error("an act the option did not bind was let through")
|
|
||||||
}
|
|
||||||
ack, _ := a.Option("silence")
|
|
||||||
if err := ack.Performs(Act{"anything": "x"}); err != nil {
|
|
||||||
t.Errorf("an acknowledgement that bound nothing: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The digests are over named fields in a canonical encoding (the review of 2026-10-09): not over Go's
|
|
||||||
// encoding of a struct, so a field added to Ask or Option, a JSON tag renamed, or a map built in another
|
|
||||||
// order changes nothing it should not — and every field that is shown or bound is in it.
|
|
||||||
func TestDigestsAreCanonicalOverNamedFields(t *testing.T) {
|
|
||||||
a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"})
|
|
||||||
b := Act{}
|
|
||||||
b["arg.delivery"] = "hq"
|
|
||||||
b["verb"] = "release"
|
|
||||||
a2, _ := ActDigest(b)
|
|
||||||
if a1 != a2 {
|
|
||||||
t.Error("the order an act was built in changed its digest")
|
|
||||||
}
|
|
||||||
// A value cannot be read as two: "a"+"b=c" is not "a=b"+"c".
|
|
||||||
x, _ := ActDigest(Act{"a": "b\n1:c"})
|
|
||||||
y, _ := ActDigest(Act{"a": "b", "c": ""})
|
|
||||||
if x == y {
|
|
||||||
t.Error("two different acts encode the same")
|
|
||||||
}
|
|
||||||
if _, err := ActDigest(Act{}); err == nil {
|
|
||||||
t.Error("an empty act was digested")
|
|
||||||
}
|
|
||||||
// The encoding, by hand: any language reproduces it.
|
|
||||||
sum := sha256.Sum256([]byte("novox.act.v1\n4:verb\n7:release\n"))
|
|
||||||
if got, _ := ActDigest(Act{"verb": "release"}); got != "sha256:"+hex.EncodeToString(sum[:]) {
|
|
||||||
t.Errorf("the act's encoding is not the one written down: %s", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every exported field of Ask and Option is in the digest: changing any one changes it.
|
|
||||||
base := held()
|
|
||||||
mutations := map[string]func(*Ask){
|
|
||||||
"ID": func(a *Ask) { a.ID = "other" },
|
|
||||||
"Headline": func(a *Ask) { a.Headline += "!" },
|
|
||||||
"Explanation": func(a *Ask) { a.Explanation += "!" },
|
|
||||||
"Who": func(a *Ask) { a.Who = "someone" },
|
|
||||||
"Expires": func(a *Ask) { a.Expires = a.Expires.Add(time.Nanosecond) },
|
|
||||||
"OnExpiry": func(a *Ask) { a.OnExpiry += "!" },
|
|
||||||
"About": func(a *Ask) { a.About += "!" },
|
|
||||||
"Urgent": func(a *Ask) { a.Urgent = !a.Urgent },
|
|
||||||
"Options": func(a *Ask) { a.Options = a.Options[:1] },
|
|
||||||
"Option.ID": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].ID = "x" },
|
|
||||||
"Option.Label": func(a *Ask) {
|
|
||||||
a.Options = append([]Option(nil), a.Options...)
|
|
||||||
a.Options[0].Label += "!"
|
|
||||||
},
|
|
||||||
"Option.Does": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].Does += "!" },
|
|
||||||
"Option.Level": func(a *Ask) {
|
|
||||||
a.Options = append([]Option(nil), a.Options...)
|
|
||||||
a.Options[0].Level = Destroy
|
|
||||||
},
|
|
||||||
"Option.Binds": func(a *Ask) {
|
|
||||||
a.Options = append([]Option(nil), a.Options...)
|
|
||||||
a.Options[0].Binds = "sha256:00"
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for name, mutate := range mutations {
|
|
||||||
c := base
|
|
||||||
mutate(&c)
|
|
||||||
if c.Digest() == base.Digest() {
|
|
||||||
t.Errorf("changing %s left the digest the same", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Whole and Details (issue 383) are in the digest once given, and change it; an ask without either digests
|
|
||||||
// exactly as before they existed — the encoding by hand, so no build can drift from it unseen.
|
|
||||||
plain := Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: now, OnExpiry: "o", About: "a",
|
|
||||||
Options: []Option{{ID: "ok", Label: "OK", Does: "d", Level: Acknowledge}}}
|
|
||||||
byHand := sha256.Sum256([]byte("novox.ask.v1\n1:p\n1:h\n1:e\n8:operator\n20:2026-10-08T14:00:00Z\n1:o\n1:a\n5:false\n1:1\n" +
|
|
||||||
"2:ok\n2:OK\n1:d\n11:acknowledge\n0:\n"))
|
|
||||||
if plain.Digest() != "sha256:"+hex.EncodeToString(byHand[:]) {
|
|
||||||
t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", plain.Digest())
|
|
||||||
}
|
|
||||||
withWhole, withDetails := plain, plain
|
|
||||||
withWhole.Whole = "e, whole"
|
|
||||||
withDetails.Details = "fingerprint 1234"
|
|
||||||
if withWhole.Digest() == plain.Digest() || withDetails.Digest() == plain.Digest() || withWhole.Digest() == withDetails.Digest() {
|
|
||||||
t.Error("Whole or Details left the digest the same")
|
|
||||||
}
|
|
||||||
both := withWhole
|
|
||||||
both.Details = "fingerprint 1234"
|
|
||||||
changed := both
|
|
||||||
changed.Whole += "!"
|
|
||||||
if changed.Digest() == both.Digest() {
|
|
||||||
t.Error("changing Whole beside Details left the digest the same")
|
|
||||||
}
|
|
||||||
// A field added to Ask or Option without being added to Digest fails here.
|
|
||||||
if n := reflect.TypeOf(Ask{}).NumField(); n != 12 {
|
|
||||||
t.Errorf("Ask has %d fields, and Digest covers 12: add the new one to Digest and to this test", n)
|
|
||||||
}
|
|
||||||
if n := reflect.TypeOf(Option{}).NumField(); n != 5 {
|
|
||||||
t.Errorf("Option has %d fields, and Digest covers 5: add the new one to Digest and to this test", n)
|
|
||||||
}
|
|
||||||
// The same instant in another zone is the same ask.
|
|
||||||
z := base
|
|
||||||
z.Expires = z.Expires.In(time.FixedZone("x", 3600))
|
|
||||||
if z.Digest() != base.Digest() {
|
|
||||||
t.Error("the expiry's zone changed the digest")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An ask that says no expiry is answered by no warrant (the confirmation review of 2026-10-09): nothing could
|
|
||||||
// show the answer was in time.
|
|
||||||
func TestAWarrantForAnAskWithNoExpiryIsRefused(t *testing.T) {
|
|
||||||
a := held()
|
|
||||||
a.Expires = time.Time{}
|
|
||||||
w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release",
|
|
||||||
Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42"}, AskDigest: a.Digest(),
|
|
||||||
At: now.Add(time.Minute)}
|
|
||||||
if _, err := w.For("mesh-delivery", a); err == nil {
|
|
||||||
t.Error("a warrant for an ask with no expiry was taken")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An explanation that reads at a glance passes; a wall of text says each way it is one (novox/hq issue 639).
|
|
||||||
func TestAnExplanationReadsAtAGlance(t *testing.T) {
|
|
||||||
if err := Glance("Your agents' memory follows you to every machine. A file changed on two keeps both.\n\n• Only the memory folders are carried\n• A conflict reaches you as a warning"); err != nil {
|
|
||||||
t.Fatalf("a summary and two points: %v", err)
|
|
||||||
}
|
|
||||||
var wall strings.Builder
|
|
||||||
wall.WriteString("ADR 0343 was proposed by fix-367 and approved by the knowledge base's own review.")
|
|
||||||
for i := 1; i <= 13; i++ {
|
|
||||||
wall.WriteString("\n" + strings.Repeat("A clause lead. ", 3))
|
|
||||||
}
|
|
||||||
err := Glance(wall.String())
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "13 points") {
|
|
||||||
t.Fatalf("thirteen clause lines pass as a glance: %v", err)
|
|
||||||
}
|
|
||||||
if err := Glance(strings.Repeat("word ", 70)); err == nil || !strings.Contains(err.Error(), "summary is 349") {
|
|
||||||
t.Fatalf("a long summary passes: %v", err)
|
|
||||||
}
|
|
||||||
if err := Glance("short.\n" + strings.Repeat("p", PointLength+1)); err == nil || !strings.Contains(err.Error(), "point 1") {
|
|
||||||
t.Fatalf("a long point passes: %v", err)
|
|
||||||
}
|
|
||||||
if err := Glance(" \n"); err == nil {
|
|
||||||
t.Fatal("an empty explanation passes")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older
|
|
||||||
// than it reads every message it read before.
|
|
||||||
func TestAMessageCarriesItsNoteApart(t *testing.T) {
|
|
||||||
raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."})
|
|
||||||
if !strings.Contains(string(raw), `"note":"Asked by x."`) {
|
|
||||||
t.Fatalf("the note is not encoded: %s", raw)
|
|
||||||
}
|
|
||||||
raw, _ = json.Marshal(Message{Handle: "h", Title: "t"})
|
|
||||||
if strings.Contains(string(raw), "note") {
|
|
||||||
t.Fatalf("an empty note is encoded: %s", raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WholeDetails is digested when given, and an ask without it digests as before it existed: a router and an asker
|
|
||||||
// of different builds still agree on every such ask.
|
|
||||||
func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) {
|
|
||||||
a := Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"}
|
|
||||||
before := a.Digest()
|
|
||||||
a.WholeDetails = "+ shares: media=/storage/media"
|
|
||||||
if a.Digest() == before {
|
|
||||||
t.Fatal("the whole details are not in the digest")
|
|
||||||
}
|
|
||||||
b := a
|
|
||||||
b.WholeDetails = "+ shares: media=/storage/other"
|
|
||||||
if a.Digest() == b.Digest() {
|
|
||||||
t.Fatal("two whole details digest the same")
|
|
||||||
}
|
|
||||||
a.WholeDetails = ""
|
|
||||||
if a.Digest() != before {
|
|
||||||
t.Fatal("an ask without whole details digests otherwise than before")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No two asks encode the same (the review of mesh-sdk #25): whole details given beside nothing else are not read as
|
|
||||||
// a Whole and Details that happen to spell the same values, nor as any other split of the three fields.
|
|
||||||
func TestWholeDetailsCollideWithNoOtherAsk(t *testing.T) {
|
|
||||||
base := Ask{ID: "a", Headline: "h", Explanation: "e"}
|
|
||||||
shapes := []Ask{}
|
|
||||||
for _, f := range []struct{ whole, details, wholeDetails string }{
|
|
||||||
{"", "", "+ x: 1"},
|
|
||||||
{"whole-details", "+ x: 1", ""},
|
|
||||||
{"", "+ x: 1", ""},
|
|
||||||
{"+ x: 1", "", ""},
|
|
||||||
{"", "", "whole-details"},
|
|
||||||
{"", "+ x: 1", "+ x: 1"},
|
|
||||||
{"+ x: 1", "+ x: 1", ""},
|
|
||||||
{"", "", ""},
|
|
||||||
} {
|
|
||||||
a := base
|
|
||||||
a.Whole, a.Details, a.WholeDetails = f.whole, f.details, f.wholeDetails
|
|
||||||
shapes = append(shapes, a)
|
|
||||||
}
|
|
||||||
seen := map[string]int{}
|
|
||||||
for i, a := range shapes {
|
|
||||||
if j, ok := seen[a.Digest()]; ok {
|
|
||||||
t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a)
|
|
||||||
}
|
|
||||||
seen[a.Digest()] = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package asks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel
|
|
||||||
// seat, `mesh.seat.operator-channel.accept.condition.<publisher>` (novox/hq ADR 0437 §9). The router reads the
|
|
||||||
// verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is
|
|
||||||
// refused when empty or when it holds a dot, a wildcard or whitespace.
|
|
||||||
func ConditionSubject(publisher string) (string, error) {
|
|
||||||
if publisher == "" {
|
|
||||||
return "", errors.New("asks: the condition's publisher is empty")
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) {
|
|
||||||
return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher)
|
|
||||||
}
|
|
||||||
return "mesh.seat." + Seat + ".accept.condition." + publisher, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConditionVersion is the version of the condition value this package writes and the only one it reads.
|
|
||||||
const ConditionVersion = 1
|
|
||||||
|
|
||||||
// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not
|
|
||||||
// ConditionVersion. Test it with errors.Is.
|
|
||||||
var ErrUnknownConditionVersion = errors.New("asks: unknown condition version")
|
|
||||||
|
|
||||||
// The words a condition's explanation opens with (novox/hq ADR 0253 §1).
|
|
||||||
const (
|
|
||||||
NothingToDo = "Nothing for you to do."
|
|
||||||
NeedsYou = "Needs you: "
|
|
||||||
)
|
|
||||||
|
|
||||||
// The severities and states of a condition.
|
|
||||||
const (
|
|
||||||
SeverityUrgent = "urgent"
|
|
||||||
SeverityWarning = "warning"
|
|
||||||
StateRaised = "raised"
|
|
||||||
StateCleared = "cleared"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scope is what a condition is about, under the controller's names.
|
|
||||||
type Scope struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Machine string `json:"machine,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action is one answer a notification offers, under the controller's names.
|
|
||||||
type Action struct {
|
|
||||||
Label string `json:"label"`
|
|
||||||
Verb string `json:"verb"`
|
|
||||||
Machine string `json:"machine,omitempty"`
|
|
||||||
Arguments map[string]string `json:"arguments,omitempty"`
|
|
||||||
Level Level `json:"level,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key,
|
|
||||||
// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs
|
|
||||||
// only its key, subject and state. The router alone chooses the channels.
|
|
||||||
type Condition struct {
|
|
||||||
V int `json:"v"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Severity string `json:"severity,omitempty"`
|
|
||||||
Subject Scope `json:"subject"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Summary string `json:"summary,omitempty"`
|
|
||||||
Headline string `json:"headline,omitempty"`
|
|
||||||
Needs string `json:"needs,omitempty"`
|
|
||||||
Explanation string `json:"explanation,omitempty"`
|
|
||||||
Resolved string `json:"resolved,omitempty"`
|
|
||||||
Actions []Action `json:"actions,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Condition) check() error {
|
|
||||||
if c.Key == "" {
|
|
||||||
return errors.New("asks: a condition has no key")
|
|
||||||
}
|
|
||||||
if c.Subject.Scope == "" || c.Subject.ID == "" {
|
|
||||||
return errors.New("asks: a condition's subject needs its scope and id")
|
|
||||||
}
|
|
||||||
switch c.State {
|
|
||||||
case StateCleared:
|
|
||||||
return nil
|
|
||||||
case StateRaised:
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State)
|
|
||||||
}
|
|
||||||
switch c.Severity {
|
|
||||||
case SeverityUrgent, SeverityWarning:
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity)
|
|
||||||
}
|
|
||||||
if c.Headline == "" {
|
|
||||||
return errors.New("asks: a raised condition has no headline")
|
|
||||||
}
|
|
||||||
if n := len([]rune(c.Headline)); n > HeadlineLength {
|
|
||||||
return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength)
|
|
||||||
}
|
|
||||||
verdict := NothingToDo
|
|
||||||
if c.Needs != "" {
|
|
||||||
verdict = NeedsYou + c.Needs
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(c.Explanation, verdict) {
|
|
||||||
return fmt.Errorf("asks: the condition's explanation must open with %q", verdict)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse.
|
|
||||||
func EncodeCondition(c Condition) ([]byte, error) {
|
|
||||||
if err := c.check(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.V = ConditionVersion
|
|
||||||
return json.Marshal(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what
|
|
||||||
// EncodeCondition refuses.
|
|
||||||
func DecodeCondition(data []byte) (Condition, error) {
|
|
||||||
var probe struct {
|
|
||||||
V *int `json:"v"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &probe); err != nil {
|
|
||||||
return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err)
|
|
||||||
}
|
|
||||||
if probe.V == nil {
|
|
||||||
return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion)
|
|
||||||
}
|
|
||||||
if *probe.V != ConditionVersion {
|
|
||||||
return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion)
|
|
||||||
}
|
|
||||||
var c Condition
|
|
||||||
if err := json.Unmarshal(data, &c); err != nil {
|
|
||||||
return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err)
|
|
||||||
}
|
|
||||||
if err := c.check(); err != nil {
|
|
||||||
return Condition{}, err
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SameCondition says whether two conditions are the same value.
|
|
||||||
func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) }
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
package asks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConditionSubject(t *testing.T) { // 1202/AC1
|
|
||||||
s, err := ConditionSubject("mesh-delivery-teller")
|
|
||||||
if err != nil || s != "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller" {
|
|
||||||
t.Fatalf("subject = %q, %v", s, err)
|
|
||||||
}
|
|
||||||
// The router splits an accept subject in six parts and reads the verb at 4 and the caller at 5.
|
|
||||||
parts := strings.Split(s, ".")
|
|
||||||
if len(parts) != 6 || parts[4] != "condition" || parts[5] != "mesh-delivery-teller" {
|
|
||||||
t.Errorf("subject %q does not keep the router's shape: %v", s, parts)
|
|
||||||
}
|
|
||||||
for _, bad := range []string{"", "a.b", "a*", "a>", "a b", "a\tb", "a\nb"} {
|
|
||||||
if _, err := ConditionSubject(bad); err == nil {
|
|
||||||
t.Errorf("publisher %q accepted", bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func raisedCondition() Condition {
|
|
||||||
return Condition{
|
|
||||||
Key: "delivery.stuck.mesh-mail", Severity: "warning",
|
|
||||||
Subject: Scope{Scope: "module", ID: "mesh-mail", Machine: "ace"}, State: "raised",
|
|
||||||
Summary: "plan 12 stopped", Headline: "Delivery of mesh-mail is stuck",
|
|
||||||
Needs: "approve the merge.", Explanation: "Needs you: approve the merge. The plan waits.",
|
|
||||||
Resolved: "The delivery of mesh-mail moved on.",
|
|
||||||
Actions: []Action{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace",
|
|
||||||
Arguments: map[string]string{"why": ""}, Level: Approve}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearedCondition() Condition {
|
|
||||||
return Condition{Key: "delivery.stuck.mesh-mail", Subject: Scope{Scope: "module", ID: "mesh-mail"}, State: "cleared"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConditionRoundTrip(t *testing.T) { // 1202/AC2
|
|
||||||
for _, c := range []Condition{raisedCondition(), clearedCondition()} {
|
|
||||||
data, err := EncodeCondition(c)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s: %v", c.State, err)
|
|
||||||
}
|
|
||||||
var probe map[string]any
|
|
||||||
if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) {
|
|
||||||
t.Fatalf("%s: %s does not carry v:1", c.State, data)
|
|
||||||
}
|
|
||||||
c.V = ConditionVersion
|
|
||||||
got, err := DecodeCondition(data)
|
|
||||||
if err != nil || !SameCondition(got, c) {
|
|
||||||
t.Errorf("%s round trip: %+v %v", c.State, got, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data, _ := EncodeCondition(raisedCondition())
|
|
||||||
var probe map[string]any
|
|
||||||
_ = json.Unmarshal(data, &probe)
|
|
||||||
for _, name := range []string{"key", "severity", "subject", "summary", "headline", "needs", "explanation", "resolved", "actions", "state"} {
|
|
||||||
if _, ok := probe[name]; !ok {
|
|
||||||
t.Errorf("the JSON has no %q: %s", name, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
subject := probe["subject"].(map[string]any)
|
|
||||||
for _, name := range []string{"scope", "id", "machine"} {
|
|
||||||
if _, ok := subject[name]; !ok {
|
|
||||||
t.Errorf("the subject has no %q: %s", name, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action := probe["actions"].([]any)[0].(map[string]any)
|
|
||||||
for _, name := range []string{"label", "verb", "machine", "arguments", "level"} {
|
|
||||||
if _, ok := action[name]; !ok {
|
|
||||||
t.Errorf("the action has no %q: %s", name, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, in := range []string{`{"key":"k"}`, `{"v":2,"key":"k"}`} {
|
|
||||||
if _, err := DecodeCondition([]byte(in)); !errors.Is(err, ErrUnknownConditionVersion) {
|
|
||||||
t.Errorf("decode of %s = %v, want ErrUnknownConditionVersion", in, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConditionVerdict(t *testing.T) { // 1202/AC3
|
|
||||||
c := raisedCondition()
|
|
||||||
c.Needs, c.Explanation = "", "The plan waits."
|
|
||||||
_, err := EncodeCondition(c)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "Nothing for you to do.") {
|
|
||||||
t.Errorf("empty needs without its verdict: %v", err)
|
|
||||||
}
|
|
||||||
c.Explanation = "Nothing for you to do. The plan waits."
|
|
||||||
if _, err := EncodeCondition(c); err != nil {
|
|
||||||
t.Errorf("empty needs with its verdict refused: %v", err)
|
|
||||||
}
|
|
||||||
c = raisedCondition()
|
|
||||||
c.Explanation = "Nothing for you to do. The plan waits."
|
|
||||||
_, err = EncodeCondition(c)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "Needs you: approve the merge.") {
|
|
||||||
t.Errorf("needs set with the wrong verdict: %v", err)
|
|
||||||
}
|
|
||||||
c.Explanation = "Needs you: something else. The plan waits."
|
|
||||||
if _, err = EncodeCondition(c); err == nil {
|
|
||||||
t.Error("needs set with other needs in the verdict accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConditionRefused(t *testing.T) { // 1202/AC4
|
|
||||||
mutate := func(f func(*Condition)) error {
|
|
||||||
c := raisedCondition()
|
|
||||||
f(&c)
|
|
||||||
_, err := EncodeCondition(c)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for name, f := range map[string]func(*Condition){
|
|
||||||
"severity": func(c *Condition) { c.Severity = "critical" },
|
|
||||||
"state": func(c *Condition) { c.State = "open" },
|
|
||||||
"key": func(c *Condition) { c.Key = "" },
|
|
||||||
"headline": func(c *Condition) { c.Headline = "" },
|
|
||||||
"long headline": func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength+1) },
|
|
||||||
} {
|
|
||||||
if err := mutate(f); err == nil {
|
|
||||||
t.Errorf("a condition with a bad %s accepted", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := mutate(func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength) }); err != nil {
|
|
||||||
t.Errorf("a headline of the greatest length refused: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := EncodeCondition(clearedCondition()); err != nil {
|
|
||||||
t.Errorf("a cleared condition with only key, subject and state refused: %v", err)
|
|
||||||
}
|
|
||||||
c := clearedCondition()
|
|
||||||
c.Key = ""
|
|
||||||
if _, err := EncodeCondition(c); err == nil {
|
|
||||||
t.Error("a cleared condition without its key accepted")
|
|
||||||
}
|
|
||||||
c = clearedCondition()
|
|
||||||
c.State = "gone"
|
|
||||||
if _, err := EncodeCondition(c); err == nil {
|
|
||||||
t.Error("a condition with an unknown state accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"path"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **One ask, one whole job** (novox/hq ADR 0525 §3). A build module runs a pulled ask with its own step tools' bodies,
|
|
||||||
// the same ones its request and reply tools run (ADR 0525 §8), in the order the job names: a build ask fetches, then
|
|
||||||
// builds or copies each artifact; a check ask fetches, digests the level, takes the kept verdicts and tests the rest,
|
|
||||||
// the packages whose tests the change touches first. The answers are the tools' own, whole, so the asker judges them as
|
|
||||||
// it judged the tools' answers.
|
|
||||||
|
|
||||||
// StepFunc is one step tool's body: its arguments in, its answer out.
|
|
||||||
type StepFunc func(ctx context.Context, args map[string]any) (any, error)
|
|
||||||
|
|
||||||
// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's.
|
|
||||||
type AskSteps struct {
|
|
||||||
Fetch, Build, Copy, Digest, Test StepFunc
|
|
||||||
// Toolchain is the toolchain the module builds with now; a build ask naming another is refused. Nil checks nothing
|
|
||||||
// (an image or an archive names none).
|
|
||||||
Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error)
|
|
||||||
// Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is
|
|
||||||
// kept and how many tests it counted. Nil takes none.
|
|
||||||
Kept func(ctx context.Context, level, digest string) (tests int, found bool, err error)
|
|
||||||
// Release removes a workspace once the ask's steps on it ended (the module's release tool's body); nil leaves it to
|
|
||||||
// the module's sweep.
|
|
||||||
Release func(workspace string) error
|
|
||||||
Logf func(format string, args ...any)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
|
|
||||||
func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) {
|
|
||||||
if ask.Build != nil {
|
|
||||||
return steps.build(ctx, *ask.Build)
|
|
||||||
}
|
|
||||||
if ask.Check != nil {
|
|
||||||
return steps.check(ctx, *ask.Check)
|
|
||||||
}
|
|
||||||
return nil, Refuse("the ask %s holds no job", ask.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func raw(v any) (json.RawMessage, error) {
|
|
||||||
if r, ok := v.(json.RawMessage); ok {
|
|
||||||
return r, nil
|
|
||||||
}
|
|
||||||
return json.Marshal(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetched is what an ask reads of a fetch's answer.
|
|
||||||
type fetched struct {
|
|
||||||
Workspace string `json:"workspace"`
|
|
||||||
Merge string `json:"merge"`
|
|
||||||
Conflicts []string `json:"conflicts"`
|
|
||||||
Changed []string `json:"changed"`
|
|
||||||
Modules []string `json:"modules"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessage, fetched, error) {
|
|
||||||
if s.Fetch == nil {
|
|
||||||
return nil, fetched{}, Refuse("this build module fetches nothing")
|
|
||||||
}
|
|
||||||
out, err := s.Fetch(ctx, args)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fetched{}, err
|
|
||||||
}
|
|
||||||
r, err := raw(out)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fetched{}, err
|
|
||||||
}
|
|
||||||
var f fetched
|
|
||||||
if err := json.Unmarshal(r, &f); err != nil || f.Workspace == "" {
|
|
||||||
return r, f, fmt.Errorf("the fetch answered no workspace: %s", r)
|
|
||||||
}
|
|
||||||
return r, f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) {
|
|
||||||
answer := delivery.BuildAnswer{}
|
|
||||||
if job.Toolchain != nil && s.Toolchain != nil {
|
|
||||||
tc, err := s.Toolchain(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if tc.Version != job.Toolchain.Version || tc.Recipe != job.Toolchain.Recipe {
|
|
||||||
return nil, Refuse("the ask is for %s (recipe %s) and this node builds with %s (recipe %s)",
|
|
||||||
job.Toolchain.Version, job.Toolchain.Recipe, tc.Version, tc.Recipe)
|
|
||||||
}
|
|
||||||
answer.Toolchain = &tc
|
|
||||||
}
|
|
||||||
var f fetched
|
|
||||||
defer func() { s.release(f.Workspace) }()
|
|
||||||
for _, a := range job.Artifacts {
|
|
||||||
if a.From == "" && f.Workspace == "" {
|
|
||||||
r, got, err := s.fetch(ctx, job.Fetch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, candidate := job.Fetch["base"]; candidate {
|
|
||||||
// A candidate is built from its head merged onto the trunk; a head that does not merge leaves the tree at the
|
|
||||||
// trunk, and building that would store the trunk's build under the candidate's fingerprint.
|
|
||||||
if len(got.Conflicts) > 0 {
|
|
||||||
return nil, fmt.Errorf("the head does not merge into the trunk: files in conflict: %s", strings.Join(got.Conflicts, ", "))
|
|
||||||
}
|
|
||||||
if got.Merge == "" {
|
|
||||||
return nil, fmt.Errorf("the fetch with a base answered no merge: %s", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f = got
|
|
||||||
}
|
|
||||||
var (
|
|
||||||
out any
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if a.From != "" {
|
|
||||||
if s.Copy == nil {
|
|
||||||
return nil, Refuse("this build module copies no artifact (%s)", a.Artifact)
|
|
||||||
}
|
|
||||||
out, err = s.Copy(ctx, map[string]any{"module": job.Module, "artifact": a.Artifact, "from": a.From})
|
|
||||||
} else {
|
|
||||||
if s.Build == nil {
|
|
||||||
return nil, Refuse("this build module builds no artifact (%s)", a.Artifact)
|
|
||||||
}
|
|
||||||
args := map[string]any{"workspace": f.Workspace, "module": job.Path, "artifact": a.Artifact}
|
|
||||||
if job.Publish {
|
|
||||||
args["publish"] = true
|
|
||||||
}
|
|
||||||
out, err = s.Build(ctx, args)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", a.Artifact, err)
|
|
||||||
}
|
|
||||||
r, err := raw(out)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var built struct {
|
|
||||||
Digest string `json:"digest"`
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(r, &built); err != nil || built.Digest == "" {
|
|
||||||
return nil, fmt.Errorf("the build of %s answered no digest: %s", a.Artifact, r)
|
|
||||||
}
|
|
||||||
answer.Artifacts = append(answer.Artifacts, delivery.AskBuilt{Artifact: a.Artifact, Digest: built.Digest, Reference: built.Reference})
|
|
||||||
}
|
|
||||||
return answer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) {
|
|
||||||
r, f, err := s.fetch(ctx, job.Fetch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer s.release(f.Workspace)
|
|
||||||
answer := delivery.CheckAnswer{Fetch: r}
|
|
||||||
if len(f.Conflicts) > 0 {
|
|
||||||
return answer, nil // the asker reads the conflict from the fetch's answer: a verdict, not an error
|
|
||||||
}
|
|
||||||
if s.Test == nil {
|
|
||||||
return nil, Refuse("this build module tests nothing")
|
|
||||||
}
|
|
||||||
runner := ""
|
|
||||||
if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) {
|
|
||||||
runner = job.Runner
|
|
||||||
}
|
|
||||||
for _, level := range job.Levels {
|
|
||||||
la := delivery.LevelAnswer{Level: level}
|
|
||||||
passed, err := s.level(ctx, job, level, f, runner, &la)
|
|
||||||
answer.Levels = append(answer.Levels, la)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !passed {
|
|
||||||
return answer, nil // the first level that did not pass ends the check (ADR 0484 clause 13)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return answer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// level runs one level of a check ask and says whether it passed.
|
|
||||||
func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string, f fetched, runner string, la *delivery.LevelAnswer) (bool, error) {
|
|
||||||
withRunner := func(args map[string]any) map[string]any {
|
|
||||||
if runner != "" {
|
|
||||||
args["runner"] = runner
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
test := func(args map[string]any) (bool, error) {
|
|
||||||
out, err := s.Test(ctx, args)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
r, err := raw(out)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
la.Tests = append(la.Tests, r)
|
|
||||||
var t struct {
|
|
||||||
Passed bool `json:"passed"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(r, &t)
|
|
||||||
return t.Passed, nil
|
|
||||||
}
|
|
||||||
if s.Digest == nil {
|
|
||||||
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
|
|
||||||
for _, priority := range []bool{true, false} {
|
|
||||||
for _, dir := range f.Modules {
|
|
||||||
if changesTestIn(f.Changed, dir) != priority {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
passed, err := test(withRunner(map[string]any{"workspace": f.Workspace, "module": dir, "level": level}))
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("the %s level of %s: %w", level, dir, err)
|
|
||||||
}
|
|
||||||
if !passed {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
todo, all, keepRunner := s.plan(ctx, job, level, f, runner, withRunner, la)
|
|
||||||
if !keepRunner {
|
|
||||||
runner = "" // an installed module that ignores the runner judges the level as it judges any other (ADR 0535 §2)
|
|
||||||
}
|
|
||||||
for _, priority := range []bool{true, false} {
|
|
||||||
args := withRunner(map[string]any{"workspace": f.Workspace, "level": level})
|
|
||||||
if job.Run != "" {
|
|
||||||
args["run"] = job.Run
|
|
||||||
}
|
|
||||||
if all {
|
|
||||||
if priority {
|
|
||||||
continue // nothing to put first: the level runs whole after
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var run []string
|
|
||||||
for _, p := range todo {
|
|
||||||
if changesTestOf(f.Changed, p) == priority {
|
|
||||||
run = append(run, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(run) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
args["packages"] = run
|
|
||||||
}
|
|
||||||
passed, err := test(args)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("the %s level: %w", level, err)
|
|
||||||
}
|
|
||||||
if !passed {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// release removes an ask's workspace, saying a failure: the module's sweep removes it later.
|
|
||||||
func (s AskSteps) release(workspace string) {
|
|
||||||
if workspace == "" || s.Release == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.Release(workspace); err != nil && s.Logf != nil {
|
|
||||||
s.Logf("the workspace %s is left to the sweep: %v", workspace, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// plan digests the level and takes the kept verdicts: the packages left to test, or all when the level runs whole, and
|
|
||||||
// whether the runner stays. A try of some tests runs whole, digesting nothing (a partial run proves no package). Under the
|
|
||||||
// candidate's runner the digest is asked as the candidate: when the candidate answers it, every package runs and nothing
|
|
||||||
// is digested for keeping (ADR 0535 §4); when the installed module answers it (it does not take the runner), the level is
|
|
||||||
// planned as any other and the runner is dropped. The level runs whole and takes nothing when the digest names no package
|
|
||||||
// or does not cover the siblings the fetch placed (ADR 0444, L5).
|
|
||||||
func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string, f fetched, runner string, withRunner func(map[string]any) map[string]any, la *delivery.LevelAnswer) ([]string, bool, bool) {
|
|
||||||
if job.Run != "" {
|
|
||||||
return nil, true, true
|
|
||||||
}
|
|
||||||
out, err := s.Digest(ctx, withRunner(map[string]any{"workspace": f.Workspace, "level": level}))
|
|
||||||
if err != nil {
|
|
||||||
if s.Logf != nil {
|
|
||||||
s.Logf("no digest of the %s level, so it runs whole: %v", level, err)
|
|
||||||
}
|
|
||||||
return nil, true, true
|
|
||||||
}
|
|
||||||
r, err := raw(out)
|
|
||||||
if err != nil {
|
|
||||||
return nil, true, true
|
|
||||||
}
|
|
||||||
var d delivery.LevelDigest // packages kept as a sorted list, never keyed by a package path (issue 1930)
|
|
||||||
if err := json.Unmarshal(r, &d); err != nil {
|
|
||||||
return nil, true, true
|
|
||||||
}
|
|
||||||
if runner != "" {
|
|
||||||
if strings.HasPrefix(d.JudgedBy, "candidate ") {
|
|
||||||
la.Judged = d.JudgedBy
|
|
||||||
return nil, true, true // the candidate decides which verdicts would be taken: all run, nothing kept
|
|
||||||
}
|
|
||||||
runner = ""
|
|
||||||
}
|
|
||||||
if len(d.Packages) == 0 || !slices.Equal(d.Beside, besideOf(job.Fetch)) {
|
|
||||||
return nil, true, runner != "" // a digest that names nothing or not the siblings keys no verdict: the level runs whole
|
|
||||||
}
|
|
||||||
la.Digest = &d
|
|
||||||
var todo []string
|
|
||||||
for _, pd := range d.Packages {
|
|
||||||
p := pd.Package
|
|
||||||
if job.Kept && s.Kept != nil {
|
|
||||||
tests, found, err := s.Kept(ctx, level, pd.Digest)
|
|
||||||
if err != nil && s.Logf != nil {
|
|
||||||
s.Logf("the kept verdict of %s at %s cannot be read, so it runs: %v", p, level, err)
|
|
||||||
}
|
|
||||||
if err == nil && found {
|
|
||||||
la.Kept = append(la.Kept, delivery.KeptTaken{Package: p, Digest: pd.Digest, Tests: tests})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
todo = append(todo, p)
|
|
||||||
}
|
|
||||||
return todo, false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// besideOf are the siblings a fetch's arguments place, as a digest echoes them: `<owner/repo> <commit>`, sorted.
|
|
||||||
func besideOf(fetch map[string]any) []string {
|
|
||||||
raw, err := json.Marshal(fetch["beside"])
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var refs []struct {
|
|
||||||
Repository string `json:"repository"`
|
|
||||||
Commit string `json:"commit"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(raw, &refs)
|
|
||||||
var out []string
|
|
||||||
for _, r := range refs {
|
|
||||||
out = append(out, r.Repository+" "+r.Commit)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// changesUnder says whether a path the merge changes is under prefix.
|
|
||||||
func changesUnder(changed []string, prefix string) bool {
|
|
||||||
for _, p := range changed {
|
|
||||||
if strings.HasPrefix(p, prefix) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isTestFile says whether a repository-relative path is a test file: Go's _test.go, and a TypeScript module's .test.
|
|
||||||
// and .spec. files and test directories.
|
|
||||||
func isTestFile(p string) bool {
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(p, "_test.go"):
|
|
||||||
return true
|
|
||||||
case strings.Contains(p, ".test.") || strings.Contains(p, ".spec."):
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, seg := range strings.Split(path.Dir(p), "/") {
|
|
||||||
if seg == "__tests__" || seg == "test" || seg == "tests" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// changesTestOf says whether the merge changes a test file of the Go package with the import path pkg: the file's
|
|
||||||
// directory and the import path agree on their last segments.
|
|
||||||
func changesTestOf(changed []string, pkg string) bool {
|
|
||||||
for _, f := range changed {
|
|
||||||
if !strings.HasSuffix(f, "_test.go") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dir := path.Dir(f)
|
|
||||||
if dir == pkg || strings.HasSuffix(pkg, "/"+dir) || strings.HasSuffix(dir, "/"+pkg) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// changesTestIn says whether the merge changes a test file inside the directory of a Node module.
|
|
||||||
func changesTestIn(changed []string, dir string) bool {
|
|
||||||
for _, f := range changed {
|
|
||||||
if !isTestFile(f) || strings.HasSuffix(f, "_test.go") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if dir == "." || dir == "" || strings.HasPrefix(f, strings.TrimSuffix(dir, "/")+"/") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
const runCommit = "0123456789abcdef0123456789abcdef01234567"
|
|
||||||
|
|
||||||
// recorder is a build module's step tools that record each call.
|
|
||||||
type recorder struct {
|
|
||||||
calls []string
|
|
||||||
fetch map[string]any
|
|
||||||
tests map[string]bool // package or module directory -> passed
|
|
||||||
digests map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *recorder) steps() AskSteps {
|
|
||||||
call := func(name string, answer func(args map[string]any) any) StepFunc {
|
|
||||||
return func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
b, _ := json.Marshal(args)
|
|
||||||
r.calls = append(r.calls, name+" "+string(b))
|
|
||||||
return answer(args), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return AskSteps{
|
|
||||||
Fetch: call("fetch", func(map[string]any) any { return r.fetch }),
|
|
||||||
Build: call("build", func(args map[string]any) any {
|
|
||||||
return map[string]any{"digest": "sha256:" + args["artifact"].(string), "reference": "artifact-store://" + args["artifact"].(string)}
|
|
||||||
}),
|
|
||||||
Copy: call("copy", func(args map[string]any) any { return map[string]any{"digest": "sha256:copied"} }),
|
|
||||||
Digest: call("digest", func(map[string]any) any { return map[string]any{"level": "unit", "packages": r.digests} }),
|
|
||||||
Test: call("test", func(args map[string]any) any {
|
|
||||||
passed := true
|
|
||||||
if pkgs, ok := args["packages"].([]string); ok {
|
|
||||||
for _, p := range pkgs {
|
|
||||||
passed = passed && r.tests[p]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if dir, ok := args["module"].(string); ok {
|
|
||||||
passed = r.tests[dir]
|
|
||||||
}
|
|
||||||
return map[string]any{"passed": passed, "tests": 1}
|
|
||||||
}),
|
|
||||||
Kept: func(ctx context.Context, level, digest string) (int, bool, error) { return 7, digest == "d-kept", nil },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ask(job any) delivery.StepAsk {
|
|
||||||
a := delivery.StepAsk{ID: NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "t", At: time.Now()}
|
|
||||||
switch j := job.(type) {
|
|
||||||
case delivery.BuildJob:
|
|
||||||
a.Build = &j
|
|
||||||
case delivery.CheckJob:
|
|
||||||
a.Check = &j
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1887/A3: a build ask fetches once, builds each artifact from the workspace with publish, copies a pinned one without a
|
|
||||||
// fetch, and answers each artifact's digest and reference.
|
|
||||||
func TestRunAskBuilds(t *testing.T) {
|
|
||||||
r := &recorder{fetch: map[string]any{"workspace": "/w/1"}}
|
|
||||||
steps := r.steps()
|
|
||||||
var released []string
|
|
||||||
steps.Release = func(ws string) error { released = append(released, ws); return nil }
|
|
||||||
out, err := RunAsk(context.Background(), steps, ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
|
|
||||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
|
|
||||||
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := strings.Join(r.calls, "\n")
|
|
||||||
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
|
|
||||||
fetch {"commit":"` + runCommit + `","repository":"novox/x"}
|
|
||||||
build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"}
|
|
||||||
build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
|
|
||||||
}
|
|
||||||
if len(released) != 1 || released[0] != "/w/1" {
|
|
||||||
t.Errorf("released %v, want the one workspace", released)
|
|
||||||
}
|
|
||||||
a := out.(delivery.BuildAnswer)
|
|
||||||
if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" {
|
|
||||||
t.Fatalf("answer %+v", a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A build ask for another toolchain is refused, so its step ends refused and the asker reads why.
|
|
||||||
func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
|
||||||
r := &recorder{fetch: map[string]any{"workspace": "/w"}}
|
|
||||||
s := r.steps()
|
|
||||||
s.Toolchain = func(context.Context) (delivery.ToolchainUsed, error) {
|
|
||||||
return delivery.ToolchainUsed{Version: "go1.27", Recipe: "3"}, nil
|
|
||||||
}
|
|
||||||
_, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
|
|
||||||
Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
|
|
||||||
var ref *Refusal
|
|
||||||
if !errors.As(err, &ref) || len(r.calls) != 0 {
|
|
||||||
t.Fatalf("err %v, calls %v", err, r.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1887/A3, A5a, issue 1887 design limit 1: a check ask runs the levels of one check after one fetch: it digests the unit
|
|
||||||
// level, takes the kept verdicts, tests the packages whose tests changed first, and stops at the first failure, running
|
|
||||||
// no later level.
|
|
||||||
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
|
||||||
r := &recorder{
|
|
||||||
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
|
|
||||||
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b", "x/pkg/k": "d-kept"},
|
|
||||||
tests: map[string]bool{"x/pkg/a": true, "x/pkg/b": false},
|
|
||||||
}
|
|
||||||
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration}, Kept: true,
|
|
||||||
Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "base": runCommit}}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := strings.Join(r.calls, "\n")
|
|
||||||
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
|
|
||||||
digest {"level":"unit","workspace":"/w"}
|
|
||||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}`
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
|
|
||||||
}
|
|
||||||
a := out.(delivery.CheckAnswer)
|
|
||||||
if len(a.Levels) != 1 {
|
|
||||||
t.Fatalf("levels %+v, want the unit level alone", a.Levels)
|
|
||||||
}
|
|
||||||
u := a.Levels[0]
|
|
||||||
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 1 || u.Digest == nil {
|
|
||||||
t.Fatalf("the unit level %+v", u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADR 0535 §2, §4: a merge that changes the build module's own code is judged by the candidate's runner: its digest says
|
|
||||||
// the candidate ran, and every package runs with nothing kept; an installed module that ignores the runner is planned as
|
|
||||||
// any other, the runner dropped. A digest that does not cover the fetch's siblings runs the level whole too.
|
|
||||||
func TestRunAskRunsWholeUnderTheRunnerOrWithoutTheSiblings(t *testing.T) {
|
|
||||||
for _, tc := range []struct{ judged, want string }{
|
|
||||||
{"candidate 999", `digest {"level":"unit","runner":"workspace","workspace":"/w"}
|
|
||||||
test {"level":"unit","runner":"workspace","workspace":"/w"}`},
|
|
||||||
{"installed", `digest {"level":"unit","runner":"workspace","workspace":"/w"}`},
|
|
||||||
} {
|
|
||||||
r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": []string{"modules/mesh-build-go/x.go"}},
|
|
||||||
digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}}
|
|
||||||
steps := r.steps()
|
|
||||||
digest := steps.Digest
|
|
||||||
steps.Digest = func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
_, _ = digest(ctx, args)
|
|
||||||
return map[string]any{"level": "unit", "packages": r.digests, "judged_by": tc.judged}, nil
|
|
||||||
}
|
|
||||||
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
|
|
||||||
Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := strings.Join(r.calls[1:], "\n"); got != tc.want {
|
|
||||||
t.Errorf("judged by %s: calls\n%s\nwant\n%s", tc.judged, got, tc.want)
|
|
||||||
}
|
|
||||||
l := out.(delivery.CheckAnswer).Levels[0]
|
|
||||||
if tc.judged == "installed" && (len(l.Kept) != 1 || l.Judged != "") {
|
|
||||||
t.Errorf("an installed module's level %+v, want the kept verdict taken", l)
|
|
||||||
}
|
|
||||||
if tc.judged != "installed" && (len(l.Kept) != 0 || l.Digest != nil || l.Judged != tc.judged) {
|
|
||||||
t.Errorf("the candidate's level %+v, want nothing kept and the program named", l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r := &recorder{fetch: map[string]any{"workspace": "/w"}, digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}}
|
|
||||||
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
|
|
||||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "beside": []any{map[string]any{"repository": "novox/mesh-sdk", "commit": runCommit}}}}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if l := out.(delivery.CheckAnswer).Levels[0]; len(l.Kept) != 0 || l.Digest != nil {
|
|
||||||
t.Fatalf("a digest without the siblings kept %+v", l)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Node check tests each module directory, the one whose tests changed first; a conflict ends the ask with the fetch
|
|
||||||
// alone, a verdict for the asker.
|
|
||||||
func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
|
|
||||||
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
|
|
||||||
tests: map[string]bool{"a": true, "b": true}}
|
|
||||||
s := r.steps()
|
|
||||||
s.Digest = nil
|
|
||||||
if _, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if fmt.Sprint(r.calls[1:]) != `[test {"level":"unit","module":"b","workspace":"/w"} test {"level":"unit","module":"a","workspace":"/w"}]` {
|
|
||||||
t.Fatalf("calls %v", r.calls)
|
|
||||||
}
|
|
||||||
r = &recorder{fetch: map[string]any{"workspace": "/w", "conflicts": []string{"go.mod"}}}
|
|
||||||
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
||||||
if err != nil || len(r.calls) != 1 || len(out.(delivery.CheckAnswer).Levels) != 0 {
|
|
||||||
t.Fatalf("a conflict: %v %v", err, r.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RegistryWait is how long a push waits for an artifact store that refuses connections, longer than its nightly
|
|
||||||
// collection holds it still (about a minute and a half, issue 457); RegistryFirstPause is the first pause, doubling
|
|
||||||
// to RegistryMostPause. Variables so a test shortens them.
|
|
||||||
var (
|
|
||||||
RegistryWait = 5 * time.Minute
|
|
||||||
RegistryFirstPause = time.Second
|
|
||||||
RegistryMostPause = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// BlobReference is where a pushed bundle or archive is, as a done key names it and the controller delivers from
|
|
||||||
// (novox/hq ADR 0498 clause 1): artifact-store://<module>/<artifact>/blobs/<digest>, the store's address left out
|
|
||||||
// (issue 102).
|
|
||||||
func BlobReference(module, artifact, digest string) string {
|
|
||||||
return "artifact-store://" + module + "/" + artifact + "/blobs/" + digest
|
|
||||||
}
|
|
||||||
|
|
||||||
// PushBlob puts body into the artifact store at registry (host:port) as a blob of repository (<module>/<artifact>) by
|
|
||||||
// its digest (`sha256:` and hex), from the build module's own process, after the build step ended (novox/hq ADR 0498
|
|
||||||
// clauses 1 and 2): a blob the store already holds is done without an upload; otherwise it is uploaded (POST
|
|
||||||
// blobs/uploads/, then PUT with the digest). A store refusing connections is waited out up to RegistryWait; any other
|
|
||||||
// refusal fails, naming the store's status and its own words, for the caller's `not stored:` error. Bytes that are not
|
|
||||||
// their digest are refused before anything is sent.
|
|
||||||
func PushBlob(ctx context.Context, client *http.Client, registry, repository, digest string, body []byte) error {
|
|
||||||
sum := sha256.Sum256(body)
|
|
||||||
if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest {
|
|
||||||
return fmt.Errorf("the blob is %s, not the digest %s it was to be pushed as", got, digest)
|
|
||||||
}
|
|
||||||
if client == nil {
|
|
||||||
client = http.DefaultClient
|
|
||||||
}
|
|
||||||
base := "http://" + registry + "/v2/" + repository + "/blobs/"
|
|
||||||
var present bool
|
|
||||||
if err := waitForStore(ctx, registry, func() error {
|
|
||||||
var err error
|
|
||||||
present, err = blobPresent(ctx, client, base+digest)
|
|
||||||
return err
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if present {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var location string
|
|
||||||
if err := waitForStore(ctx, registry, func() error {
|
|
||||||
var err error
|
|
||||||
location, err = startUpload(ctx, client, base+"uploads/")
|
|
||||||
return err
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
target, err := url.Parse(base)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
loc, err := target.Parse(location)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("the store answered the upload's location %q, which is not a URL: %w", location, err)
|
|
||||||
}
|
|
||||||
q := loc.Query()
|
|
||||||
q.Set("digest", digest)
|
|
||||||
loc.RawQuery = q.Encode()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, loc.String(), bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("putting %s of %s: %w", digest, repository, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusCreated {
|
|
||||||
return storeSaid(resp, "putting "+digest+" of "+repository)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func blobPresent(ctx context.Context, client *http.Client, at string) (bool, error) {
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, at, nil)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
switch resp.StatusCode {
|
|
||||||
case http.StatusOK:
|
|
||||||
return true, nil
|
|
||||||
case http.StatusNotFound:
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, fmt.Errorf("asking the store for %s: it answered %s", at, resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func startUpload(ctx context.Context, client *http.Client, at string) (string, error) {
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, at, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusAccepted {
|
|
||||||
return "", storeSaid(resp, "starting an upload at "+at)
|
|
||||||
}
|
|
||||||
location := resp.Header.Get("Location")
|
|
||||||
if location == "" {
|
|
||||||
return "", fmt.Errorf("starting an upload at %s: the store named no location", at)
|
|
||||||
}
|
|
||||||
return location, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeSaid is the store's refusal: its status and the first of its own words.
|
|
||||||
func storeSaid(resp *http.Response, what string) error {
|
|
||||||
words, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
||||||
return fmt.Errorf("%s: the store answered %s: %s", what, resp.Status, strings.TrimSpace(string(words)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// refused is whether an error is a connection refused.
|
|
||||||
func refused(err error) bool {
|
|
||||||
return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForStore runs try, and while it fails because the store refuses connections, tries again with a growing pause
|
|
||||||
// until RegistryWait has passed. A refusal is the one error waited for: nothing was sent, so trying again does nothing
|
|
||||||
// twice.
|
|
||||||
func waitForStore(ctx context.Context, registry string, try func() error) error {
|
|
||||||
err := try()
|
|
||||||
if !refused(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
started, pause := time.Now(), RegistryFirstPause
|
|
||||||
for {
|
|
||||||
left := RegistryWait - time.Since(started)
|
|
||||||
if left <= 0 {
|
|
||||||
return fmt.Errorf("the artifact store at %s refused every connection for %s, longer than its nightly collection holds it still, so it is down, not paused: %w",
|
|
||||||
registry, time.Since(started).Round(time.Millisecond), err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return fmt.Errorf("stopped while waiting for the artifact store at %s: %w (last: %v)", registry, ctx.Err(), err)
|
|
||||||
case <-time.After(min(pause, left)):
|
|
||||||
}
|
|
||||||
pause = min(pause*2, RegistryMostPause)
|
|
||||||
if err = try(); !refused(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
func digestOfBytes(b []byte) string {
|
|
||||||
h := sha256.Sum256(b)
|
|
||||||
return "sha256:" + hex.EncodeToString(h[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// registry is an artifact store as far as a blob push needs one: HEAD a blob, start an upload, PUT it with its digest.
|
|
||||||
type registry struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
blobs map[string][]byte
|
|
||||||
calls []string
|
|
||||||
put int // the status a PUT answers, 201 when 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *registry) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
r.calls = append(r.calls, req.Method+" "+req.URL.Path)
|
|
||||||
switch {
|
|
||||||
case req.Method == http.MethodHead && strings.Contains(req.URL.Path, "/blobs/sha256:"):
|
|
||||||
if _, ok := r.blobs[req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]]; ok {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
case req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/blobs/uploads/"):
|
|
||||||
w.Header().Set("Location", strings.TrimSuffix(req.URL.Path, "/")+"/u1?state=x")
|
|
||||||
w.WriteHeader(http.StatusAccepted)
|
|
||||||
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/blobs/uploads/u1"):
|
|
||||||
if r.put != 0 {
|
|
||||||
w.WriteHeader(r.put)
|
|
||||||
_, _ = io.WriteString(w, `{"errors":[{"code":"DIGEST_INVALID","message":"provided digest did not match uploaded content"}]}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, _ := io.ReadAll(req.Body)
|
|
||||||
d := req.URL.Query().Get("digest")
|
|
||||||
if req.URL.Query().Get("state") != "x" || d != digestOfBytes(body) {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.blobs[d] = body
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
default:
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A done build's bundle or archive is pushed to the artifact store by its build module (novox/hq ADR 0498 clauses 1
|
|
||||||
// and 2): a blob the store already holds is done with no upload; one it lacks is uploaded by digest; its reference is
|
|
||||||
// the artifact-store form the builder's key and the controller check.
|
|
||||||
func TestABlobIsPushedOnceByItsDigest(t *testing.T) {
|
|
||||||
reg := ®istry{blobs: map[string][]byte{}}
|
|
||||||
srv := httptest.NewServer(reg)
|
|
||||||
defer srv.Close()
|
|
||||||
addr := strings.TrimPrefix(srv.URL, "http://")
|
|
||||||
body := []byte("the packed bundle")
|
|
||||||
d := digestOfBytes(body)
|
|
||||||
if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(reg.blobs[d]) != string(body) {
|
|
||||||
t.Fatalf("the store holds %q", reg.blobs[d])
|
|
||||||
}
|
|
||||||
reg.calls = nil
|
|
||||||
if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(reg.calls) != 1 || !strings.HasPrefix(reg.calls[0], "HEAD ") {
|
|
||||||
t.Fatalf("a blob the store holds was uploaded again: %v", reg.calls)
|
|
||||||
}
|
|
||||||
ref := BlobReference("mail", "code", d)
|
|
||||||
if ref != "artifact-store://mail/code/blobs/"+d {
|
|
||||||
t.Fatalf("BlobReference = %q", ref)
|
|
||||||
}
|
|
||||||
b := delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("4", 64),
|
|
||||||
Artifacts: []delivery.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: d, Reference: ref}}}
|
|
||||||
if _, err := delivery.EncodeBuilderBuild(b); err != nil {
|
|
||||||
t.Fatalf("the key refuses the reference BlobReference gives: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A store that refuses the upload fails the push naming the store's own words; bytes that are not the digest are
|
|
||||||
// refused before anything is sent.
|
|
||||||
func TestAPushTheStoreRefusesSaysTheStoresWords(t *testing.T) {
|
|
||||||
reg := ®istry{blobs: map[string][]byte{}, put: http.StatusBadRequest}
|
|
||||||
srv := httptest.NewServer(reg)
|
|
||||||
defer srv.Close()
|
|
||||||
addr := strings.TrimPrefix(srv.URL, "http://")
|
|
||||||
body := []byte("x")
|
|
||||||
err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes(body), body)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "provided digest did not match") || !strings.Contains(err.Error(), "400") {
|
|
||||||
t.Fatalf("a refused upload said %v", err)
|
|
||||||
}
|
|
||||||
reg.calls = nil
|
|
||||||
err = PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes([]byte("other")), body)
|
|
||||||
if err == nil || len(reg.calls) != 0 {
|
|
||||||
t.Fatalf("bytes that are not their digest: %v, calls %v", err, reg.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A store refusing connections (its nightly collection holds it still) is waited out, up to RegistryWait (issue 457).
|
|
||||||
func TestAPushWaitsOutAStoreRefusingConnections(t *testing.T) {
|
|
||||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
addr := l.Addr().String()
|
|
||||||
l.Close() // refusing now
|
|
||||||
reg := ®istry{blobs: map[string][]byte{}}
|
|
||||||
go func() {
|
|
||||||
time.Sleep(300 * time.Millisecond)
|
|
||||||
l2, err := net.Listen("tcp", addr)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = http.Serve(l2, reg)
|
|
||||||
}()
|
|
||||||
beforeWait, beforePause := RegistryWait, RegistryFirstPause
|
|
||||||
RegistryWait, RegistryFirstPause = 3*time.Second, 100*time.Millisecond
|
|
||||||
defer func() { RegistryWait, RegistryFirstPause = beforeWait, beforePause }()
|
|
||||||
body := []byte("late")
|
|
||||||
if err := PushBlob(context.Background(), http.DefaultClient, addr, "mail/code", digestOfBytes(body), body); err != nil {
|
|
||||||
t.Fatalf("a store that came back was not waited for: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
l3, _ := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
gone := l3.Addr().String()
|
|
||||||
l3.Close()
|
|
||||||
RegistryWait = 300 * time.Millisecond
|
|
||||||
err = PushBlob(context.Background(), http.DefaultClient, gone, "mail/code", digestOfBytes(body), body)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "refused every connection") {
|
|
||||||
t.Fatalf("a store that stayed down said %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +1,7 @@
|
|||||||
package buildstep
|
package buildstep
|
||||||
|
|
||||||
import (
|
// CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how
|
||||||
"encoding/json"
|
// many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names
|
||||||
"fmt"
|
// a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config)
|
||||||
"os"
|
// lays the node's value into the module's settings file.
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CapacitySetting is the build modules' setting of each node's build capacity (novox/hq ADR 0525 §5): `<node>=<asks at
|
|
||||||
// once>` pairs separated by commas, as the builder's was (ADR 0485 §1). Each build module declares it with
|
|
||||||
// DefaultCapacities as its default, and the controller lays it into the module's settings file.
|
|
||||||
const CapacitySetting = "capacity"
|
const CapacitySetting = "capacity"
|
||||||
|
|
||||||
// DefaultCapacities is the setting's default, the builder's before R4g: the control-node (novox) and the laptop (g14)
|
|
||||||
// build nothing.
|
|
||||||
const DefaultCapacities = "shanks=3,ace=2,g14=0,novox=0"
|
|
||||||
|
|
||||||
// NodeCapacity is node's build capacity, read from the module's settings file at path at every call, so a change takes
|
|
||||||
// effect at the next pull; with no path it reads fallback. A node the setting does not name runs one ask at a time (as
|
|
||||||
// the builder's did). A file or a setting that cannot be read gives 0 and says why: pulling on a setting nobody can read
|
|
||||||
// could build on a node that must not.
|
|
||||||
func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func() int {
|
|
||||||
say := func(format string, args ...any) {
|
|
||||||
if logf != nil {
|
|
||||||
logf(format, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return func() int {
|
|
||||||
setting := fallback
|
|
||||||
if path != "" {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
say("the build capacity cannot be read, so %s pulls nothing: %v", node, err)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var s map[string]any
|
|
||||||
if err := json.Unmarshal(raw, &s); err != nil {
|
|
||||||
say("the settings file %s is not JSON, so %s pulls nothing: %v", path, node, err)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
setting, _ = s[CapacitySetting].(string)
|
|
||||||
}
|
|
||||||
caps, err := buildrun.ParseCapacity(setting)
|
|
||||||
if err != nil {
|
|
||||||
say("%v, so %s pulls nothing", fmt.Errorf("the build capacity: %w", err), node)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return (&buildrun.Places{Capacity: caps}).CapacityOf(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls
|
|
||||||
// nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing.
|
|
||||||
func TestNodeCapacity(t *testing.T) {
|
|
||||||
if got := NodeCapacity("", DefaultCapacities, "novox", nil)(); got != 0 {
|
|
||||||
t.Errorf("novox = %d by default, want 0", got)
|
|
||||||
}
|
|
||||||
if got := NodeCapacity("", DefaultCapacities, "shanks", nil)(); got != 3 {
|
|
||||||
t.Errorf("shanks = %d by default, want 3", got)
|
|
||||||
}
|
|
||||||
file := filepath.Join(t.TempDir(), "settings.json")
|
|
||||||
os.WriteFile(file, []byte(`{"capacity":"shanks=1,ace=2"}`), 0o600)
|
|
||||||
capacity := NodeCapacity(file, DefaultCapacities, "shanks", nil)
|
|
||||||
if got := capacity(); got != 1 {
|
|
||||||
t.Errorf("shanks = %d from the file, want 1", got)
|
|
||||||
}
|
|
||||||
os.WriteFile(file, []byte(`{"capacity":"shanks=4"}`), 0o600)
|
|
||||||
if got := capacity(); got != 4 {
|
|
||||||
t.Errorf("shanks = %d after the change, want 4", got)
|
|
||||||
}
|
|
||||||
if got := NodeCapacity(file, "", "pi", nil)(); got != 1 {
|
|
||||||
t.Errorf("a node not named = %d, want 1", got)
|
|
||||||
}
|
|
||||||
os.WriteFile(file, []byte(`{"capacity":"shanks=lots"}`), 0o600)
|
|
||||||
if got := capacity(); got != 0 {
|
|
||||||
t.Errorf("an unreadable setting = %d, want 0", got)
|
|
||||||
}
|
|
||||||
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), DefaultCapacities, "shanks", nil)(); got != 0 {
|
|
||||||
t.Errorf("a missing file = %d, want 0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// credentialField is the node runtime's rule for a state value (mesh-tools node-tools/internal/bus/state.go, novox/hq
|
|
||||||
// ADR 0201), copied whole: the first field anywhere in the value whose name, lower-cased and without - _ and spaces, ends
|
|
||||||
// in a credential word, or "". A step's end is refused when it answers a field.
|
|
||||||
func credentialField(value json.RawMessage) string {
|
|
||||||
endings := []string{"password", "passwd", "secret", "token", "credential", "credentials",
|
|
||||||
"authorization", "apikey", "privatekey", "accesskey", "cookie"}
|
|
||||||
var v any
|
|
||||||
if json.Unmarshal(value, &v) != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var walk func(any) string
|
|
||||||
walk = func(v any) string {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
keys := make([]string, 0, len(t))
|
|
||||||
for k := range t {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
norm := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(k))
|
|
||||||
for _, end := range endings {
|
|
||||||
if strings.HasSuffix(norm, end) {
|
|
||||||
return k
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found := walk(t[k]); found != "" {
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for _, x := range t {
|
|
||||||
if found := walk(x); found != "" {
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return walk(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runtimeState is a steps state that refuses a value as the node runtime does, and keeps every value it took.
|
|
||||||
type runtimeState struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
took map[string][]delivery.Step
|
|
||||||
refused int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRuntimeState() *runtimeState { return &runtimeState{took: map[string][]delivery.Step{}} }
|
|
||||||
|
|
||||||
func (s *runtimeState) put(key string, value any) (uint64, error) {
|
|
||||||
raw, _ := value.(json.RawMessage)
|
|
||||||
if f := credentialField(raw); f != "" {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.refused++
|
|
||||||
s.mu.Unlock()
|
|
||||||
return 0, fmt.Errorf("steps.%s carries a field %q, which names a credential", key, f)
|
|
||||||
}
|
|
||||||
st, err := delivery.DecodeStep(raw)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.took[key] = append(s.took[key], st)
|
|
||||||
return uint64(len(s.took[key])), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *runtimeState) last(key string) (delivery.Step, bool) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
t := s.took[key]
|
|
||||||
if len(t) == 0 {
|
|
||||||
return delivery.Step{}, false
|
|
||||||
}
|
|
||||||
return t[len(t)-1], true
|
|
||||||
}
|
|
||||||
|
|
||||||
func fastEnds(t *testing.T) {
|
|
||||||
attempts, retry := EndAttempts, EndRetry
|
|
||||||
EndAttempts, EndRetry = 2, time.Millisecond
|
|
||||||
t.Cleanup(func() { EndAttempts, EndRetry = attempts, retry })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1930/AC1: given a check of a repository with the package .../internal/token, when its ask's answer is stored as the
|
|
||||||
// step's end, then no field is keyed by a package path: the runtime's credential rule takes the value, and the package
|
|
||||||
// and its digest read back from the stored end.
|
|
||||||
func TestADigestOfAPackageNamedTokenIsStoredAsAList(t *testing.T) {
|
|
||||||
fastEnds(t)
|
|
||||||
r := &recorder{
|
|
||||||
fetch: map[string]any{"workspace": "/w"},
|
|
||||||
digests: map[string]string{"x/internal/token": "d-token", "x/pkg/a": "d-a"},
|
|
||||||
tests: map[string]bool{"x/internal/token": true, "x/pkg/a": true},
|
|
||||||
}
|
|
||||||
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit},
|
|
||||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
old, _ := json.Marshal(map[string]any{"levels": []any{map[string]any{"level": "unit", "digest": map[string]any{"packages": r.digests}}}})
|
|
||||||
if credentialField(old) != "x/internal/token" {
|
|
||||||
t.Fatalf("the rule does not refuse the form before issue 1930 (%s): the test proves nothing", old)
|
|
||||||
}
|
|
||||||
st := newRuntimeState()
|
|
||||||
if err := finish(st.put, "shanks.a1", delivery.Step{ID: "a1", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, out, nil, t.Logf); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
s, ok := st.last("shanks.a1")
|
|
||||||
if !ok || s.State != delivery.StepPassed || st.refused != 0 {
|
|
||||||
t.Fatalf("the end %+v (written %v, refused %d), want it passed and taken at once", s, ok, st.refused)
|
|
||||||
}
|
|
||||||
var a delivery.CheckAnswer
|
|
||||||
if err := json.Unmarshal(s.Result, &a); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(a.Levels) != 1 || a.Levels[0].Digest == nil {
|
|
||||||
t.Fatalf("levels %+v, want the unit level with its digest", a.Levels)
|
|
||||||
}
|
|
||||||
want := []delivery.PackageDigest{{Package: "x/internal/token", Digest: "d-token"}, {Package: "x/pkg/a", Digest: "d-a"}}
|
|
||||||
if got := a.Levels[0].Digest.Packages; fmt.Sprint(got) != fmt.Sprint(want) {
|
|
||||||
t.Errorf("packages %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(s.Result), `"package":"x/internal/token"`) {
|
|
||||||
t.Errorf("the stored end does not name the package as a value: %s", s.Result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1930/AC2: given an end the state refuses every time, when finish writes it, then it logs the refusal and writes a small
|
|
||||||
// failed end without the result that names the write's error; and when no end can be written at all it answers an error.
|
|
||||||
func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) {
|
|
||||||
fastEnds(t)
|
|
||||||
big := errors.New("the value carries a field \"x/internal/token\", which names a credential")
|
|
||||||
var refused int
|
|
||||||
var stored []delivery.Step
|
|
||||||
put := func(key string, value any) (uint64, error) {
|
|
||||||
st, _ := delivery.DecodeStep(value.(json.RawMessage))
|
|
||||||
if len(st.Result) > 0 {
|
|
||||||
refused++
|
|
||||||
return 0, big
|
|
||||||
}
|
|
||||||
stored = append(stored, st)
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
var logged []string
|
|
||||||
logf := func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }
|
|
||||||
err := finish(put, "shanks.a2", delivery.Step{ID: "a2", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, map[string]any{"levels": 1}, nil, logf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("finish answered %v, want the small end written", err)
|
|
||||||
}
|
|
||||||
if refused != EndAttempts || len(stored) != 1 {
|
|
||||||
t.Fatalf("refused %d, stored %d, want %d refusals and one small end", refused, len(stored), EndAttempts)
|
|
||||||
}
|
|
||||||
s := stored[0]
|
|
||||||
if s.State != delivery.StepFailed || len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") ||
|
|
||||||
!strings.Contains(s.Reason, big.Error()) || !strings.Contains(s.Reason, "passed") {
|
|
||||||
t.Errorf("the small end %+v, want failed, no result, naming the write's error and that it passed", s)
|
|
||||||
}
|
|
||||||
if len(logged) == 0 || !strings.Contains(logged[0], big.Error()) {
|
|
||||||
t.Errorf("logged %q, want the refusal said", logged)
|
|
||||||
}
|
|
||||||
none := func(string, any) (uint64, error) { return 0, errors.New("the bus is gone") }
|
|
||||||
if err := finish(none, "shanks.a3", delivery.Step{ID: "a3", Tool: "check ask", Node: "shanks", Started: time.Now()}, nil, nil, logf); err == nil || !strings.Contains(err.Error(), "the bus is gone") {
|
|
||||||
t.Errorf("finish with no write possible answered %v, want the error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1930/AC2: given an ask whose end the state refuses, when it runs, then the ask is acknowledged only once its small
|
|
||||||
// failed end is written; when no end can be written at all, it is given back, never acknowledged as done.
|
|
||||||
func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) {
|
|
||||||
fastEnds(t)
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
smallOK bool
|
|
||||||
wantAcked bool
|
|
||||||
}{{"the small end written", true, true}, {"no end written", false, false}} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
src := NewFakeAsks()
|
|
||||||
a := testAsk(delivery.AskCheck)
|
|
||||||
_ = src.PublishAsk(a)
|
|
||||||
var mu sync.Mutex
|
|
||||||
var ends []delivery.Step
|
|
||||||
put := func(key string, value any) (uint64, error) {
|
|
||||||
st, err := delivery.DecodeStep(value.(json.RawMessage))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if st.State != delivery.StepRunning && (len(st.Result) > 0 || !tc.smallOK) {
|
|
||||||
return 0, errors.New("refused")
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
ends = append(ends, st)
|
|
||||||
mu.Unlock()
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: put, Capacity: func() int { return 1 },
|
|
||||||
PullWait: 10 * time.Millisecond, Logf: t.Logf,
|
|
||||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return map[string]any{"ran": true}, nil }}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go p.Pull(ctx)
|
|
||||||
waitFor(t, "the ask's fate", func() bool { ac, tm, nk := src.Fates(); return len(ac)+len(tm)+len(nk) > 0 })
|
|
||||||
cancel()
|
|
||||||
acked, termed, naked := src.Fates()
|
|
||||||
if tc.wantAcked && (len(acked) != 1 || len(naked) != 0) || !tc.wantAcked && (len(acked) != 0 || len(naked) == 0) || len(termed) != 0 {
|
|
||||||
t.Fatalf("acked %v naked %v termed %v", acked, naked, termed)
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if tc.smallOK {
|
|
||||||
last := ends[len(ends)-1]
|
|
||||||
if last.State != delivery.StepFailed || !strings.Contains(last.Reason, "refused") {
|
|
||||||
t.Errorf("the end %+v, want the small failed end", last)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package buildstep is the contract of a build step every delivery part reads (novox/hq ADR 0593 §1): a step's id, the
|
||||||
|
// fetch request, a step a previous process left running and its end, a failure's words, a module tree's shape, the
|
||||||
|
// capacity setting and the launchers' names. The runner the build modules share is the Go build module's own package
|
||||||
|
// (mesh-catalog modules/mesh-build-go/buildstep), which imports this one.
|
||||||
|
package buildstep
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package buildstep
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/sdkversion"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Failure is one test that failed, or a package or file that failed with no test to name (a build that failed, a test
|
||||||
|
// program that panicked, a test file that did not load). The JSON names are the ones mesh-build-go and the checker have
|
||||||
|
// always used; File is new and an older reader ignores it.
|
||||||
|
type Failure struct {
|
||||||
|
// Package is the Go package's import path, or the TypeScript package's directory in the workspace.
|
||||||
|
Package string `json:"package"`
|
||||||
|
// Test names the failing test, or the tests that were running when the program stopped; "" when none can be named.
|
||||||
|
Test string `json:"test,omitempty"`
|
||||||
|
// File is where the failing test is (a Go test's file and line is in its Cause; node names the test's file:line:col).
|
||||||
|
File string `json:"file,omitempty"`
|
||||||
|
// Cause is the one line that says why: the assertion's or the error's first line, a "panic:" line, a build's first
|
||||||
|
// error, or words saying how the program stopped. **Never empty, and never a passing line** (PassingLine).
|
||||||
|
Cause string `json:"cause,omitempty"`
|
||||||
|
// Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads
|
||||||
|
// only its first line.
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long
|
||||||
|
// assertion message and stays far under a bus message's or a verdict's bound.
|
||||||
|
const (
|
||||||
|
OutputBytes = 4096
|
||||||
|
LineBytes = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// By names a build module and the SDK it was built with, for a result's "by": the checker names it when a failure hides
|
||||||
|
// its cause ("the build module hid the failure (<by>)").
|
||||||
|
func By(module string) string {
|
||||||
|
return module + " with mesh-sdk " + sdkversion.Version()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassingLine says a line is a test runner's word for a test or a package that passed: node's '✔', TAP's "ok N" (at any
|
||||||
|
// indent), go test's "ok \t<pkg>", "PASS" and "--- PASS". A line of a test's own that starts "ok " is none of these. Such a line is never a failure's cause, nor part of a failure's output.
|
||||||
|
func PassingLine(l string) bool {
|
||||||
|
if strings.HasPrefix(l, "ok \t") { // go test's package line, at column 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
t := strings.TrimSpace(l)
|
||||||
|
switch {
|
||||||
|
case t == "PASS", strings.HasPrefix(t, "✔"), strings.HasPrefix(t, "--- PASS"):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return tapOk(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden says how a failure hides why it failed, or "" when it does not: it has no cause, or its cause is a passing line.
|
||||||
|
// The checker says it loudly (issue 1901): a build module that sends such a failure does not keep the contract.
|
||||||
|
func Hidden(f Failure) string {
|
||||||
|
c := strings.TrimSpace(f.Cause)
|
||||||
|
switch {
|
||||||
|
case c == "":
|
||||||
|
return "it sent no cause"
|
||||||
|
case PassingLine(c):
|
||||||
|
return fmt.Sprintf("its cause is a passing line (%q)", CutLine(c))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clip joins the last lines of a failure's output that fit OutputBytes, each cut to LineBytes, and says how many earlier
|
||||||
|
// lines it left out. **Its first line is the cause**, put ahead when the kept lines do not start with it, or, with no
|
||||||
|
// cause and lines left out, the first non-empty line, then the marker: an older checker reads only the first line.
|
||||||
|
// Passing lines are dropped.
|
||||||
|
func Clip(lines []string, cause string) string {
|
||||||
|
own := make([]string, 0, len(lines))
|
||||||
|
for _, l := range lines {
|
||||||
|
if !PassingLine(l) {
|
||||||
|
own = append(own, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = own
|
||||||
|
size, from := 0, len(lines)
|
||||||
|
for from > 0 {
|
||||||
|
n := len(CutLine(lines[from-1])) + 1
|
||||||
|
if size+n > OutputBytes && from < len(lines) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
size += n
|
||||||
|
from--
|
||||||
|
}
|
||||||
|
kept := make([]string, 0, len(lines)-from+2)
|
||||||
|
head := strings.TrimSpace(cause)
|
||||||
|
for i := 0; head == "" && i < from; i++ {
|
||||||
|
head = strings.TrimSpace(lines[i])
|
||||||
|
}
|
||||||
|
if head != "" && (from > 0 || len(lines) == 0 || strings.TrimSpace(lines[from]) != head) {
|
||||||
|
kept = append(kept, CutLine(head))
|
||||||
|
}
|
||||||
|
if from > 0 {
|
||||||
|
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
|
||||||
|
}
|
||||||
|
for _, l := range lines[from:] {
|
||||||
|
kept = append(kept, CutLine(l))
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CutLine cuts a line to LineBytes, on a character boundary.
|
||||||
|
func CutLine(l string) string {
|
||||||
|
if len(l) <= LineBytes {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return strings.ToValidUTF8(l[:LineBytes], "") + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsoleFailure is the failure of a command that is no test runner (go vet, gofmt, tsc, a build script): its cause is the
|
||||||
|
// first line that names an error (tsc's "error TS", go vet's "file.go:12:3: ..."), else the first line that is not a
|
||||||
|
// heading ("# <package>", npm's "> script"), and its output the command's lines without passing ones.
|
||||||
|
func ConsoleFailure(pkg, test, out string) Failure {
|
||||||
|
var lines []string
|
||||||
|
for _, l := range strings.Split(strings.ReplaceAll(stripANSI(out), "\r\n", "\n"), "\n") {
|
||||||
|
if t := strings.TrimSpace(l); t != "" && !PassingLine(t) {
|
||||||
|
lines = append(lines, strings.TrimRight(l, " \t"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cause := ""
|
||||||
|
for _, l := range lines {
|
||||||
|
if t := strings.TrimSpace(l); strings.Contains(t, "error") || strings.Contains(t, "Error") || goPosition.MatchString(t) {
|
||||||
|
cause = t
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
if t := strings.TrimSpace(l); cause == "" && !strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "> ") && !strings.HasPrefix(t, "npm ") {
|
||||||
|
cause = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cause == "" {
|
||||||
|
cause = test + " failed and wrote nothing that says why"
|
||||||
|
}
|
||||||
|
cause = CutLine(cause)
|
||||||
|
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)}
|
||||||
|
}
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Option changes FetchCommit.
|
|
||||||
type Option func(*fetchOptions)
|
|
||||||
|
|
||||||
type fetchOptions struct {
|
|
||||||
allowFile bool
|
|
||||||
mirror string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowFileRemote lets FetchCommit take a file:// remote. It is for a test's local repository; production
|
|
||||||
// code does not pass it, and FetchCommit then refuses anything but ssh://.
|
|
||||||
func AllowFileRemote() Option { return func(o *fetchOptions) { o.allowFile = true } }
|
|
||||||
|
|
||||||
// WithMirror makes FetchCommit (and MergeInto) borrow objects from mirror, a bare repository that UpdateMirror keeps
|
|
||||||
// in the build module's own cache (novox/hq ADR 0536 §2; issue 890): the workspace's .git/objects/info/alternates
|
|
||||||
// names the mirror's objects directory, so git transfers only the objects the mirror lacks. It is a plain file, never
|
|
||||||
// a symlink. mirror must be an absolute path of an existing directory; the workspace never writes to it.
|
|
||||||
func WithMirror(mirror string) Option { return func(o *fetchOptions) { o.mirror = mirror } }
|
|
||||||
|
|
||||||
// useMirror writes the alternates file of the repository in dir, when a mirror is set.
|
|
||||||
func useMirror(o fetchOptions, dir string) error {
|
|
||||||
if o.mirror == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
info := filepath.Join(dir, ".git", "objects", "info")
|
|
||||||
if err := os.MkdirAll(info, 0o755); err != nil {
|
|
||||||
return fmt.Errorf("buildstep: the mirror cannot be used: %w", err)
|
|
||||||
}
|
|
||||||
line := filepath.Join(o.mirror, "objects") + "\n"
|
|
||||||
if err := os.WriteFile(filepath.Join(info, "alternates"), []byte(line), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("buildstep: the mirror cannot be used: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
|
||||||
refOK = regexp.MustCompile(`^refs/[A-Za-z0-9._/-]+$`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// FetchCommit fetches one commit of a forge repository into dir, an empty directory, and checks it out detached
|
|
||||||
// (novox/hq ADR 0466 §4, §5; issue 1474). remote is ssh://<ssh name>/<owner>/<repo>.git, the ssh name being the
|
|
||||||
// one the build module's templates set up (ADR 0409 §1); commit is a full 40-hex sha; ref is optional, and names
|
|
||||||
// the pull request's head (refs/pull/<n>/head) when the commit is only there. Every git command runs through run
|
|
||||||
// as `git -C dir ...` with GIT_TERMINAL_PROMPT=0 and no credential helper, so nothing is asked for and no
|
|
||||||
// credential is used. A remote that is not ssh://, or one with user information, and a commit that is not 40
|
|
||||||
// hex digits are refused before anything runs.
|
|
||||||
func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, ref string, opts ...Option) error {
|
|
||||||
var o fetchOptions
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&o)
|
|
||||||
}
|
|
||||||
if err := checkFetch(o, dir, remote, commit, ref); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
env := []string{"GIT_TERMINAL_PROMPT=0"}
|
|
||||||
git := func(args ...string) (string, error) {
|
|
||||||
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
|
|
||||||
r := run(ctx, env, "git", full...)
|
|
||||||
if r.Status != 0 || r.Err != "" {
|
|
||||||
return "", fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
|
|
||||||
}
|
|
||||||
return r.Stdout, nil
|
|
||||||
}
|
|
||||||
if _, err := git("init", "-q"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := useMirror(o, dir); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := git("remote", "add", "origin", remote); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := git("fetch", "--no-tags", "--depth", "1", "origin", commit); err != nil {
|
|
||||||
if ref == "" {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, rerr := git("fetch", "--no-tags", "origin", ref); rerr != nil {
|
|
||||||
return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr)
|
|
||||||
}
|
|
||||||
if _, verr := git("cat-file", "-e", commit+"^{commit}"); verr != nil {
|
|
||||||
return fmt.Errorf("the commit %s is not in %s: %w", commit, ref, verr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := git("checkout", "-q", "--detach", commit); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
out, err := git("rev-parse", "HEAD")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if head := strings.TrimSpace(out); head != commit {
|
|
||||||
return fmt.Errorf("the checkout is at %s, not at the commit %s", head, commit)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateMirror brings the bare repository mirror to commit: it makes the mirror when it is absent (git init --bare,
|
|
||||||
// origin set to remote), then fetches the commit, or the ref when only the ref holds it, with the same rules as
|
|
||||||
// FetchCommit (no prompt, no credential helper, ssh:// or an allowed file:// remote, a 40-hex commit). The mirror
|
|
||||||
// keeps every commit it was brought to, so later fetches through WithMirror transfer only what is new. Callers
|
|
||||||
// serialise updates of one mirror.
|
|
||||||
func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, commit, ref string, opts ...Option) error {
|
|
||||||
var o fetchOptions
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&o)
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(mirror) {
|
|
||||||
return fmt.Errorf("buildstep: the mirror %q is not an absolute path", mirror)
|
|
||||||
}
|
|
||||||
if err := checkFetch(o, mirror, remote, commit, ref); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
env := []string{"GIT_TERMINAL_PROMPT=0"}
|
|
||||||
git := func(args ...string) error {
|
|
||||||
r := run(ctx, env, "git", append([]string{"-c", "credential.helper="}, args...)...)
|
|
||||||
if r.Status != 0 || r.Err != "" {
|
|
||||||
return fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
in := func(args ...string) error { return git(append([]string{"-C", mirror}, args...)...) }
|
|
||||||
if _, err := os.Stat(filepath.Join(mirror, "HEAD")); err != nil {
|
|
||||||
if err := git("init", "-q", "--bare", mirror); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := in("remote", "add", "origin", remote); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if err := in("remote", "set-url", "origin", remote); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := in("fetch", "--no-tags", "origin", commit); err != nil {
|
|
||||||
if ref == "" {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if rerr := in("fetch", "--no-tags", "origin", ref); rerr != nil {
|
|
||||||
return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := in("cat-file", "-e", commit+"^{commit}"); err != nil {
|
|
||||||
return fmt.Errorf("the commit %s is not in the mirror: %w", commit, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkFetch(o fetchOptions, dir, remote, commit, ref string) error {
|
|
||||||
if dir == "" {
|
|
||||||
return fmt.Errorf("buildstep: no workspace directory")
|
|
||||||
}
|
|
||||||
if o.mirror != "" {
|
|
||||||
if !filepath.IsAbs(o.mirror) {
|
|
||||||
return fmt.Errorf("buildstep: the mirror %q is not an absolute path", o.mirror)
|
|
||||||
}
|
|
||||||
if fi, err := os.Stat(o.mirror); err != nil || !fi.IsDir() {
|
|
||||||
return fmt.Errorf("buildstep: the mirror %q is not an existing directory", o.mirror)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !commitOK.MatchString(commit) {
|
|
||||||
return fmt.Errorf("buildstep: %q is not a full commit (40 hex digits)", commit)
|
|
||||||
}
|
|
||||||
if ref != "" && !refOK.MatchString(ref) {
|
|
||||||
return fmt.Errorf("buildstep: %q is not a ref (refs/...)", ref)
|
|
||||||
}
|
|
||||||
u, err := url.Parse(remote)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("buildstep: the remote is not a URL: %w", err)
|
|
||||||
}
|
|
||||||
if u.User != nil {
|
|
||||||
return fmt.Errorf("buildstep: the remote carries user information; the key is the credential")
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case u.Scheme == "ssh" && u.Host != "":
|
|
||||||
case u.Scheme == "file" && o.allowFile:
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("buildstep: the remote %q is not ssh://", remote)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstLine(s string) string {
|
|
||||||
for _, l := range strings.Split(s, "\n") {
|
|
||||||
if l = strings.TrimSpace(l); l != "" {
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
func gitIn(t *testing.T, dir string, args ...string) string {
|
|
||||||
t.Helper()
|
|
||||||
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid"}, args...)...)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1474/AC5: a local bare repository with a commit only under refs/pull/1/head (a file:// remote, allowed by the
|
|
||||||
// test-only option): the workspace's HEAD is the commit. The forge itself is proven live after the build
|
|
||||||
// modules' delivery (ADR 0466 §5).
|
|
||||||
func TestFetchCommitFromAPullRefOnly(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
bare := filepath.Join(root, "r.git")
|
|
||||||
work := filepath.Join(root, "work")
|
|
||||||
for _, d := range []string{bare, work} {
|
|
||||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gitIn(t, bare, "init", "-q", "--bare")
|
|
||||||
gitIn(t, work, "init", "-q")
|
|
||||||
if err := os.WriteFile(filepath.Join(work, "f"), []byte("x"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
gitIn(t, work, "add", "f")
|
|
||||||
gitIn(t, work, "commit", "-q", "-m", "pull request head")
|
|
||||||
commit := gitIn(t, work, "rev-parse", "HEAD")
|
|
||||||
gitIn(t, work, "push", "-q", bare, "HEAD:refs/pull/1/head")
|
|
||||||
|
|
||||||
ws := filepath.Join(root, "ws")
|
|
||||||
if err := os.MkdirAll(ws, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
err := FetchCommit(context.Background(), forgekey.ExecRunner, ws, "file://"+bare, commit, "refs/pull/1/head", AllowFileRemote())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if head := gitIn(t, ws, "rev-parse", "HEAD"); head != commit {
|
|
||||||
t.Errorf("HEAD = %s, want %s", head, commit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
const sha = "0123456789abcdef0123456789abcdef01234567"
|
|
||||||
|
|
||||||
// fakeGit records every command and answers by a rule.
|
|
||||||
type fakeGit struct {
|
|
||||||
calls []string
|
|
||||||
envs [][]string
|
|
||||||
rule func(args string) forgekey.Ran
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeGit) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
|
||||||
line := name + " " + strings.Join(args, " ")
|
|
||||||
f.calls = append(f.calls, line)
|
|
||||||
f.envs = append(f.envs, env)
|
|
||||||
if f.rule != nil {
|
|
||||||
if r := f.rule(line); r.Status != 0 || r.Stdout != "" || r.Err != "" {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(args) > 4 && args[4] == "rev-parse" {
|
|
||||||
return forgekey.Ran{Stdout: sha + "\n"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pre = "git -c credential.helper= -C /w "
|
|
||||||
|
|
||||||
// 1474/AC1: the git commands are exactly the sequence, none with a credential or an https remote.
|
|
||||||
func TestFetchCommitRunsTheSequence(t *testing.T) {
|
|
||||||
f := &fakeGit{}
|
|
||||||
if err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/novox/mesh-sdk.git", sha, ""); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := []string{
|
|
||||||
pre + "init -q",
|
|
||||||
pre + "remote add origin ssh://mesh-forge/novox/mesh-sdk.git",
|
|
||||||
pre + "fetch --no-tags --depth 1 origin " + sha,
|
|
||||||
pre + "checkout -q --detach " + sha,
|
|
||||||
pre + "rev-parse HEAD",
|
|
||||||
}
|
|
||||||
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
|
|
||||||
t.Errorf("commands:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
|
|
||||||
}
|
|
||||||
for i, c := range f.calls {
|
|
||||||
if strings.Contains(c, "https://") || strings.Contains(c, "@") || !strings.Contains(c, "credential.helper= ") {
|
|
||||||
t.Errorf("command %q carries a credential or https", c)
|
|
||||||
}
|
|
||||||
if len(f.envs[i]) != 1 || f.envs[i][0] != "GIT_TERMINAL_PROMPT=0" {
|
|
||||||
t.Errorf("env %v", f.envs[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1474/AC2: the depth-1 fetch fails and a pull ref is given: the ref is fetched and the commit verified; with no
|
|
||||||
// ref, the fetch's failure is the answer.
|
|
||||||
func TestFetchCommitFallsBackToThePullRef(t *testing.T) {
|
|
||||||
f := &fakeGit{rule: func(l string) forgekey.Ran {
|
|
||||||
if strings.Contains(l, "--depth 1") {
|
|
||||||
return forgekey.Ran{Status: 128, Stderr: "fatal: couldn't find remote ref " + sha + "\n"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{}
|
|
||||||
}}
|
|
||||||
if err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := []string{pre + "fetch --no-tags origin refs/pull/1/head", pre + "cat-file -e " + sha + "^{commit}", pre + "checkout -q --detach " + sha}
|
|
||||||
got := strings.Join(f.calls[3:6], "\n")
|
|
||||||
if got != strings.Join(want, "\n") {
|
|
||||||
t.Errorf("after the failed fetch:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
g := &fakeGit{rule: f.rule}
|
|
||||||
err := FetchCommit(context.Background(), g.run, "/w", "ssh://mesh-forge/o/r.git", sha, "")
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "couldn't find remote ref") || len(g.calls) != 3 {
|
|
||||||
t.Errorf("without a ref: %v after %d commands", err, len(g.calls))
|
|
||||||
}
|
|
||||||
|
|
||||||
h := &fakeGit{rule: func(l string) forgekey.Ran {
|
|
||||||
if strings.Contains(l, "--depth 1") || strings.Contains(l, "cat-file") {
|
|
||||||
return forgekey.Ran{Status: 1, Stderr: "no"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{}
|
|
||||||
}}
|
|
||||||
if err := FetchCommit(context.Background(), h.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err == nil {
|
|
||||||
t.Error("a commit that is not in the ref was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1474/AC3: HEAD differs from the commit after the checkout: an error naming both.
|
|
||||||
func TestFetchCommitAnswersAHeadThatIsNotTheCommit(t *testing.T) {
|
|
||||||
other := strings.Repeat("a", 40)
|
|
||||||
f := &fakeGit{rule: func(l string) forgekey.Ran {
|
|
||||||
if strings.HasSuffix(l, "rev-parse HEAD") {
|
|
||||||
return forgekey.Ran{Stdout: other + "\n"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{}
|
|
||||||
}}
|
|
||||||
err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", sha, "")
|
|
||||||
if err == nil || !strings.Contains(err.Error(), other) || !strings.Contains(err.Error(), sha) {
|
|
||||||
t.Errorf("error = %v, want both shas", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1474/AC4: an https remote, user information, a file remote without the option, or a commit that is not 40
|
|
||||||
// hex digits is refused before anything runs.
|
|
||||||
func TestFetchCommitRefusesBeforeRunningAnything(t *testing.T) {
|
|
||||||
for _, c := range []struct{ remote, commit, ref string }{
|
|
||||||
{"https://forge.example/o/r.git", sha, ""},
|
|
||||||
{"http://forge.example/o/r.git", sha, ""},
|
|
||||||
{"ssh://git@mesh-forge/o/r.git", sha, ""},
|
|
||||||
{"ssh://user:pw@mesh-forge/o/r.git", sha, ""},
|
|
||||||
{"file:///tmp/r.git", sha, ""},
|
|
||||||
{"/tmp/r.git", sha, ""},
|
|
||||||
{"ssh://mesh-forge/o/r.git", "abc123", ""},
|
|
||||||
{"ssh://mesh-forge/o/r.git", strings.ToUpper(sha), ""},
|
|
||||||
{"ssh://mesh-forge/o/r.git", "main", ""},
|
|
||||||
{"ssh://mesh-forge/o/r.git", sha, "--upload-pack=x"},
|
|
||||||
} {
|
|
||||||
f := &fakeGit{}
|
|
||||||
if err := FetchCommit(context.Background(), f.run, "/w", c.remote, c.commit, c.ref); err == nil || len(f.calls) != 0 {
|
|
||||||
t.Errorf("%+v: err %v, %d commands", c, err, len(f.calls))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f := &fakeGit{}
|
|
||||||
if err := FetchCommit(context.Background(), f.run, "/w", "file:///tmp/r.git", sha, "", AllowFileRemote()); err != nil {
|
|
||||||
t.Errorf("a file remote with the option: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Guard is a build module's check that the toolchain installed on its node is the one its recipe builds with
|
|
||||||
// (novox/hq ADR 0469 §1; issue 1473). The same guard serves ADR 0470 §4's postgres major for the integration
|
|
||||||
// level, with its own Probe and Want.
|
|
||||||
type Guard struct {
|
|
||||||
// Language is the toolchain's key part: go or typescript (delivery.ToolchainKey).
|
|
||||||
Language string
|
|
||||||
// Want is the toolchain version the recipe builds with, and Recipe the recipe's own version. The indexer
|
|
||||||
// compares both texts as they are written here.
|
|
||||||
Want, Recipe string
|
|
||||||
// Probe reads the installed version (go version, node --version).
|
|
||||||
Probe func(ctx context.Context) (installed string, err error)
|
|
||||||
Node string
|
|
||||||
// Put is the toolchains state's Put (stdio.State(delivery.ToolchainsState).Put).
|
|
||||||
Put Put
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check probes the installed version, writes it to <node>.<language> of the toolchains state every time it runs,
|
|
||||||
// and answers nil when it is the recipe's. Otherwise it answers a *Refusal naming both versions; the installed
|
|
||||||
// version is written even then, so the indexer sees that builders disagree. A probe that fails is a *Refusal
|
|
||||||
// naming its failure, and nothing is written. A write that fails is an error that is not a Refusal.
|
|
||||||
func (g Guard) Check(ctx context.Context) error {
|
|
||||||
installed, err := g.Probe(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return &Refusal{Text: fmt.Sprintf("the installed %s version could not be read: %v", g.Language, err), Want: g.Want}
|
|
||||||
}
|
|
||||||
key, err := delivery.ToolchainKey(g.Node, g.Language)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
raw, err := delivery.EncodeToolchain(delivery.Toolchain{Language: g.Language, Version: installed, Recipe: g.Recipe, At: now().UTC()})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := g.Put(key, json.RawMessage(raw)); err != nil {
|
|
||||||
return fmt.Errorf("buildstep: writing the toolchain %s: %w", key, err)
|
|
||||||
}
|
|
||||||
if installed != g.Want {
|
|
||||||
return &Refusal{Text: fmt.Sprintf("%s %s installed, the recipe builds with %s %s", g.Language, installed, g.Language, g.Want),
|
|
||||||
Want: g.Want, Have: installed}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
type toolchainKeys map[string]delivery.Toolchain
|
|
||||||
|
|
||||||
func (k toolchainKeys) put(key string, value any) (uint64, error) {
|
|
||||||
tc, err := delivery.DecodeToolchain(value.(json.RawMessage))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
k[key] = tc
|
|
||||||
return uint64(len(k)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func guardFor(k toolchainKeys, installed string, probeErr error) Guard {
|
|
||||||
return Guard{Language: "go", Want: "1.27.0", Recipe: "r7", Node: "ace", Put: k.put,
|
|
||||||
Probe: func(context.Context) (string, error) { return installed, probeErr }}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1473/AC1: the probe answers the recipe's version: nil, and <node>.go holds that version and the recipe's.
|
|
||||||
func TestCheckPassesAndWritesTheToolchain(t *testing.T) {
|
|
||||||
k := toolchainKeys{}
|
|
||||||
if err := guardFor(k, "1.27.0", nil).Check(context.Background()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
tc, ok := k["ace.go"]
|
|
||||||
if !ok || tc.Version != "1.27.0" || tc.Recipe != "r7" || tc.Language != "go" || tc.At.IsZero() {
|
|
||||||
t.Errorf("keys = %+v", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1473/AC2: another version: a Refusal naming both, and the key holds the installed version.
|
|
||||||
func TestCheckRefusesAnotherVersionAndWritesTheInstalledOne(t *testing.T) {
|
|
||||||
k := toolchainKeys{}
|
|
||||||
err := guardFor(k, "1.27.1", nil).Check(context.Background())
|
|
||||||
var ref *Refusal
|
|
||||||
if !errors.As(err, &ref) {
|
|
||||||
t.Fatalf("Check = %v, want a Refusal", err)
|
|
||||||
}
|
|
||||||
if ref.Text != "go 1.27.1 installed, the recipe builds with go 1.27.0" || ref.Want != "1.27.0" || ref.Have != "1.27.1" {
|
|
||||||
t.Errorf("refusal = %+v", ref)
|
|
||||||
}
|
|
||||||
if k["ace.go"].Version != "1.27.1" {
|
|
||||||
t.Errorf("the key holds %q, want the installed 1.27.1", k["ace.go"].Version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1473/AC3: a probe that fails: a Refusal naming the failure, and nothing put.
|
|
||||||
func TestCheckRefusesAFailedProbeAndPutsNothing(t *testing.T) {
|
|
||||||
k := toolchainKeys{}
|
|
||||||
err := guardFor(k, "", errors.New("go: not found")).Check(context.Background())
|
|
||||||
var ref *Refusal
|
|
||||||
if !errors.As(err, &ref) || !strings.Contains(ref.Text, "go: not found") {
|
|
||||||
t.Fatalf("Check = %v, want a Refusal naming the probe's failure", err)
|
|
||||||
}
|
|
||||||
if len(k) != 0 {
|
|
||||||
t.Errorf("put %v", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A failed write is an error, not a refusal: the step fails instead of refusing.
|
|
||||||
func TestCheckAnswersAFailedWriteAsAnError(t *testing.T) {
|
|
||||||
g := guardFor(toolchainKeys{}, "1.27.0", nil)
|
|
||||||
g.Put = func(string, any) (uint64, error) { return 0, errors.New("no grant") }
|
|
||||||
err := g.Check(context.Background())
|
|
||||||
var ref *Refusal
|
|
||||||
if err == nil || errors.As(err, &ref) {
|
|
||||||
t.Errorf("Check = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package buildstep
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goPosition matches a line that starts with a Go file's position, as go vet and the compiler write an error.
|
||||||
|
var goPosition = regexp.MustCompile(`^\S+\.go:\d+(:\d+)?: `)
|
||||||
|
|
||||||
|
var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
|
||||||
|
|
||||||
|
func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") }
|
||||||
|
|
||||||
|
// tapOk says a line is a passing test's "ok N - name", indented or not.
|
||||||
|
func tapOk(l string) bool {
|
||||||
|
rest, ok := strings.CutPrefix(strings.TrimSpace(l), "ok ")
|
||||||
|
return ok && rest != "" && rest[0] >= '0' && rest[0] <= '9'
|
||||||
|
}
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Bubblewrap is the program a run step runs in (novox/hq ADR 0448): a build module declares its package, which needs
|
|
||||||
// unprivileged user namespaces (issue 1308).
|
|
||||||
const Bubblewrap = "bwrap"
|
|
||||||
|
|
||||||
// Isolation is how a run step's command is shut off (novox/hq ADR 0448; issue 1309, the lead's decision L2 of R4e: one
|
|
||||||
// runner in the SDK for every build module). The fetch step holds the forge key and the network and runs none of the
|
|
||||||
// change's code; the run step runs the change's build and tests in bubblewrap with:
|
|
||||||
//
|
|
||||||
// - no network: its own network namespace, with only a loopback, so a test may start a bus or a store of its own but
|
|
||||||
// resolves no name and reaches no socket of the machine;
|
|
||||||
// - none of the account: not its home, not the forge key, not a bus login, not the runtime directory (/run), since
|
|
||||||
// none of them is bound; HOME is a directory in the step's own /tmp unless Env names one;
|
|
||||||
// - an environment of exactly Env and the runner's env: nothing of the build module's own passes;
|
|
||||||
// - the system's programs and libraries read-only, and of /etc only what a program needs to run (users, libraries,
|
|
||||||
// certificates, time zone), with a hosts file naming only the loopback;
|
|
||||||
// - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step
|
|
||||||
// downloaded) read-only;
|
|
||||||
// - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone
|
|
||||||
// (WriteCaches), so a candidate's code never changes what a later build reads.
|
|
||||||
type Isolation struct {
|
|
||||||
Workspace string
|
|
||||||
Writable []string
|
|
||||||
ReadOnly []string
|
|
||||||
Caches []string
|
|
||||||
WriteCaches bool
|
|
||||||
Env []string
|
|
||||||
// Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback.
|
|
||||||
Hosts string
|
|
||||||
}
|
|
||||||
|
|
||||||
// etcNeeded is what of /etc a program in the step may read: who the users are, where the libraries are, the
|
|
||||||
// certificates, the time zone. Not resolv.conf, not hosts: the step resolves no name.
|
|
||||||
var etcNeeded = []string{"/etc/passwd", "/etc/group", "/etc/nsswitch.conf", "/etc/ld.so.cache", "/etc/ld.so.conf",
|
|
||||||
"/etc/ld.so.conf.d", "/etc/ssl", "/etc/ca-certificates", "/etc/pki", "/etc/localtime", "/etc/os-release", "/etc/alternatives"}
|
|
||||||
|
|
||||||
// systemArgs binds the system's programs and libraries read-only: /usr, and /bin, /sbin, /lib, /lib64 as the symlinks
|
|
||||||
// they are on a merged-usr system, or bound when they are directories. A variable so a unit test fixes it.
|
|
||||||
var systemArgs = func() []string {
|
|
||||||
args := []string{"--ro-bind", "/usr", "/usr"}
|
|
||||||
for _, d := range []string{"/bin", "/sbin", "/lib", "/lib64"} {
|
|
||||||
info, err := os.Lstat(d)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
case info.Mode()&os.ModeSymlink != 0:
|
|
||||||
if target, err := os.Readlink(d); err == nil {
|
|
||||||
args = append(args, "--symlink", target, d)
|
|
||||||
}
|
|
||||||
case info.IsDir():
|
|
||||||
args = append(args, "--ro-bind", d, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range etcNeeded {
|
|
||||||
args = append(args, "--ro-bind-try", e, e)
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
// stepHome is the HOME of a step that is given none: inside its own /tmp, gone with it.
|
|
||||||
const stepHome = "/tmp/home"
|
|
||||||
|
|
||||||
func checkDir(what, dir string) error {
|
|
||||||
if dir == "" || !strings.HasPrefix(dir, "/") || path.Clean(dir) != dir || dir == "/" {
|
|
||||||
return fmt.Errorf("buildstep: the %s %q is not an absolute, clean directory other than the root", what, dir)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command is the bubblewrap command line that runs name with args shut off as iso says, env added to iso.Env. It
|
|
||||||
// refuses a directory that is not absolute and clean, or is the root, and an environment word without "=".
|
|
||||||
func (iso Isolation) Command(name string, args ...string) ([]string, error) {
|
|
||||||
return iso.command(nil, name, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (iso Isolation) command(env []string, name string, args ...string) ([]string, error) {
|
|
||||||
if err := checkDir("workspace", iso.Workspace); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, group := range []struct {
|
|
||||||
what string
|
|
||||||
dirs []string
|
|
||||||
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}} {
|
|
||||||
for _, d := range group.dirs {
|
|
||||||
if err := checkDir(group.what, d); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
words := append(append([]string{}, iso.Env...), env...)
|
|
||||||
home := false
|
|
||||||
for _, w := range words {
|
|
||||||
k, _, ok := strings.Cut(w, "=")
|
|
||||||
if !ok || k == "" {
|
|
||||||
return nil, fmt.Errorf("buildstep: the environment word %q is not NAME=value", w)
|
|
||||||
}
|
|
||||||
home = home || k == "HOME"
|
|
||||||
}
|
|
||||||
argv := []string{"--unshare-all", "--die-with-parent", "--new-session", "--clearenv",
|
|
||||||
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", stepHome}
|
|
||||||
argv = append(argv, systemArgs()...)
|
|
||||||
for _, d := range iso.ReadOnly {
|
|
||||||
argv = append(argv, "--ro-bind", d, d)
|
|
||||||
}
|
|
||||||
argv = append(argv, cacheArgs(iso.Caches, iso.WriteCaches)...)
|
|
||||||
argv = append(argv, "--bind", iso.Workspace, iso.Workspace)
|
|
||||||
for _, d := range iso.Writable {
|
|
||||||
argv = append(argv, "--bind", d, d)
|
|
||||||
}
|
|
||||||
if iso.Hosts != "" {
|
|
||||||
argv = append(argv, "--ro-bind", iso.Hosts, "/etc/hosts")
|
|
||||||
}
|
|
||||||
for _, w := range words {
|
|
||||||
k, v, _ := strings.Cut(w, "=")
|
|
||||||
argv = append(argv, "--setenv", k, v)
|
|
||||||
}
|
|
||||||
if !home {
|
|
||||||
argv = append(argv, "--setenv", "HOME", stepHome)
|
|
||||||
}
|
|
||||||
argv = append(argv, "--chdir", iso.Workspace, "--", name)
|
|
||||||
return append(argv, args...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cacheArgs binds the shared warm caches (S18): for a trunk build read-write, so the next build is warm; for any other
|
|
||||||
// read-only beneath an overlay whose writes go to a tmpfs of the step's own, so the step writes as a build does and
|
|
||||||
// nothing it wrote outlives it (bubblewrap's --tmp-overlay, an overlay mount in the step's user namespace).
|
|
||||||
func cacheArgs(caches []string, write bool) []string {
|
|
||||||
var argv []string
|
|
||||||
for _, c := range caches {
|
|
||||||
if write {
|
|
||||||
argv = append(argv, "--bind", c, c)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
argv = append(argv, "--overlay-src", c, "--tmp-overlay", c)
|
|
||||||
}
|
|
||||||
return argv
|
|
||||||
}
|
|
||||||
|
|
||||||
// loopbackHosts is the hosts file of a step: the loopback and nothing else.
|
|
||||||
const loopbackHosts = "127.0.0.1 localhost\n::1 localhost\n"
|
|
||||||
|
|
||||||
// Isolated is run with every command shut off as iso says (Command), run's env going inside the step: the build modules'
|
|
||||||
// run steps call their test, build and digest commands through it, never run itself. Each command gets a hosts file of
|
|
||||||
// its own, naming only the loopback, removed when it ends.
|
|
||||||
func Isolated(run forgekey.Runner, iso Isolation) forgekey.Runner {
|
|
||||||
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
|
||||||
dir, err := os.MkdirTemp("", "step-hosts-")
|
|
||||||
if err != nil {
|
|
||||||
return forgekey.Ran{Status: 1, Err: "the step's hosts file: " + err.Error()}
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(dir)
|
|
||||||
hosts := filepath.Join(dir, "hosts")
|
|
||||||
if err := os.WriteFile(hosts, []byte(loopbackHosts), 0o644); err != nil {
|
|
||||||
return forgekey.Ran{Status: 1, Err: "the step's hosts file: " + err.Error()}
|
|
||||||
}
|
|
||||||
iso := iso
|
|
||||||
iso.Hosts = hosts
|
|
||||||
argv, err := iso.command(env, name, args...)
|
|
||||||
if err != nil {
|
|
||||||
return forgekey.Ran{Status: 1, Err: err.Error()}
|
|
||||||
}
|
|
||||||
return run(ctx, nil, Bubblewrap, argv...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// The shared warm caches are read under a throwaway writable layer (novox/hq ADR 0448; issue 1309): a candidate's step
|
|
||||||
// writes into them as a build does, and nothing it writes outlives it or reaches a later build; only a trunk build
|
|
||||||
// writes them.
|
|
||||||
func TestACandidatesStepWritesTheCachesOnlyIntoAThrowawayLayer(t *testing.T) {
|
|
||||||
fixedSystem(t)
|
|
||||||
iso := Isolation{Workspace: "/w", Caches: []string{"/var/cache/go-build", "/var/cache/npm"}}
|
|
||||||
argv, err := iso.Command("go", "test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, c := range iso.Caches {
|
|
||||||
if !holds(argv, "--overlay-src", c, "--tmp-overlay", c) {
|
|
||||||
t.Errorf("the cache %s is not under a throwaway layer: %v", c, argv)
|
|
||||||
}
|
|
||||||
if holds(argv, "--bind", c, c) {
|
|
||||||
t.Errorf("a candidate's step writes the cache %s: %v", c, argv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
iso.WriteCaches = true
|
|
||||||
argv, _ = iso.Command("go", "build")
|
|
||||||
for _, c := range iso.Caches {
|
|
||||||
if !holds(argv, "--bind", c, c) || holds(argv, "--tmp-overlay", c) {
|
|
||||||
t.Errorf("a trunk build does not write the cache %s: %v", c, argv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
//go:build integration && isolation
|
|
||||||
|
|
||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1309/S19, the issue's proof: a run step, in bubblewrap as Isolated runs it, reads no key and no login, resolves no
|
|
||||||
// name, reaches no TCP or unix socket of the machine, finds no home of the account, and leaves the shared cache it
|
|
||||||
// wrote unchanged; the workspace it writes keeps what it wrote. It needs bubblewrap and unprivileged user namespaces
|
|
||||||
// (issue 1308) on the machine that runs it, and fails naming them when they are not there.
|
|
||||||
func TestARunStepReachesNothingAndLeavesTheCachesUnchanged(t *testing.T) {
|
|
||||||
if _, err := exec.LookPath(Bubblewrap); err != nil {
|
|
||||||
t.Fatalf("bubblewrap is not installed here, so the isolation of a run step cannot be proved (issue 1309): %v", err)
|
|
||||||
}
|
|
||||||
root := t.TempDir()
|
|
||||||
account := filepath.Join(root, "account")
|
|
||||||
key, login := filepath.Join(account, ".ssh", "id_ed25519"), filepath.Join(account, "bus-login.json")
|
|
||||||
writeFile(t, filepath.Dir(key), filepath.Base(key), "PRIVATE KEY")
|
|
||||||
writeFile(t, account, "bus-login.json", `{"password":"secret"}`)
|
|
||||||
cacheDir := filepath.Join(root, "cache")
|
|
||||||
writeFile(t, cacheDir, "warm", "warm\n")
|
|
||||||
workspace := filepath.Join(root, "work", "tree")
|
|
||||||
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
probe := filepath.Join(workspace, "probe")
|
|
||||||
if out, err := exec.Command("go", "build", "-o", probe, "./testdata/isolated-probe").CombinedOutput(); err != nil {
|
|
||||||
t.Fatalf("building the probe: %v\n%s", err, out)
|
|
||||||
}
|
|
||||||
tcp, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer tcp.Close()
|
|
||||||
socket := filepath.Join(account, "bus.sock")
|
|
||||||
unix, err := net.Listen("unix", socket)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer unix.Close()
|
|
||||||
|
|
||||||
run := Isolated(forgekey.ExecRunnerFor(0), Isolation{Workspace: workspace, Caches: []string{cacheDir}})
|
|
||||||
r := run(context.Background(), []string{"PATH=/usr/bin:/bin"}, probe, key, login, tcp.Addr().String(), socket, "localhost.localdomain.invalid", filepath.Join(cacheDir, "warm"))
|
|
||||||
if r.Status != 0 || r.Err != "" || strings.TrimSpace(r.Stdout) != "shut off" {
|
|
||||||
t.Fatalf("the run step was not shut off (status %d, %s):\n%s%s", r.Status, r.Err, r.Stdout, r.Stderr)
|
|
||||||
}
|
|
||||||
if b, _ := os.ReadFile(filepath.Join(cacheDir, "warm")); string(b) != "warm\n" {
|
|
||||||
t.Fatalf("the step changed the shared cache: %q", b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The workspace is the step's own: what it writes there stays.
|
|
||||||
r = run(context.Background(), nil, "/bin/sh", "-c", "echo built > out")
|
|
||||||
if b, err := os.ReadFile(filepath.Join(workspace, "out")); r.Status != 0 || err != nil || string(b) != "built\n" {
|
|
||||||
t.Fatalf("the step's write to its workspace did not stay (status %d, %s%s): %q %v", r.Status, r.Stderr, r.Err, b, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// pairs says whether argv holds the words of want one after another.
|
|
||||||
func holds(argv []string, want ...string) bool {
|
|
||||||
for i := 0; i+len(want) <= len(argv); i++ {
|
|
||||||
match := true
|
|
||||||
for j, w := range want {
|
|
||||||
if argv[i+j] != w {
|
|
||||||
match = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if match {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func fixedSystem(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
before := systemArgs
|
|
||||||
systemArgs = func() []string { return []string{"--ro-bind", "/usr", "/usr", "--symlink", "usr/bin", "/bin"} }
|
|
||||||
t.Cleanup(func() { systemArgs = before })
|
|
||||||
}
|
|
||||||
|
|
||||||
// A run step's command runs in bubblewrap (novox/hq ADR 0448; issue 1309): no network, no namespace shared, an
|
|
||||||
// environment of exactly what the step is given, the workspace the one tree it writes, the toolchain read-only, and
|
|
||||||
// nothing else of the machine's: not the account's home, not the forge key or a bus login, not the runtime directory,
|
|
||||||
// and no name service.
|
|
||||||
func TestARunStepRunsShutOffInBubblewrap(t *testing.T) {
|
|
||||||
fixedSystem(t)
|
|
||||||
iso := Isolation{Workspace: "/work/ab12/tree", ReadOnly: []string{"/usr/lib/go"}, Writable: []string{"/work/ab12/out"},
|
|
||||||
Env: []string{"GOFLAGS=-mod=mod", "PATH=/usr/bin"}, Hosts: "/work/ab12/hosts"}
|
|
||||||
argv, err := iso.Command("go", "test", "./...")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, want := range [][]string{
|
|
||||||
{"--unshare-all"}, {"--die-with-parent"}, {"--new-session"}, {"--clearenv"},
|
|
||||||
{"--proc", "/proc"}, {"--dev", "/dev"}, {"--tmpfs", "/tmp"},
|
|
||||||
{"--ro-bind", "/usr", "/usr"}, {"--symlink", "usr/bin", "/bin"},
|
|
||||||
{"--ro-bind", "/usr/lib/go", "/usr/lib/go"},
|
|
||||||
{"--bind", "/work/ab12/tree", "/work/ab12/tree"}, {"--bind", "/work/ab12/out", "/work/ab12/out"},
|
|
||||||
{"--ro-bind", "/work/ab12/hosts", "/etc/hosts"},
|
|
||||||
{"--setenv", "GOFLAGS", "-mod=mod"}, {"--setenv", "PATH", "/usr/bin"}, {"--setenv", "HOME", "/tmp/home"},
|
|
||||||
{"--chdir", "/work/ab12/tree"},
|
|
||||||
{"--", "go", "test", "./..."},
|
|
||||||
} {
|
|
||||||
if !holds(argv, want...) {
|
|
||||||
t.Errorf("the command has no %v: %v", want, argv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if holds(argv, "--share-net") || strings.Contains(strings.Join(argv, " "), "resolv.conf") || strings.Contains(strings.Join(argv, " "), "/run") {
|
|
||||||
t.Errorf("the command reaches the network, the name service or the runtime directory: %v", argv)
|
|
||||||
}
|
|
||||||
// The workspace is mounted after /tmp's tmpfs, so a workspace under /tmp is not hidden by it.
|
|
||||||
if strings.Index(strings.Join(argv, "\x00"), "--tmpfs\x00/tmp") > strings.Index(strings.Join(argv, "\x00"), "--bind\x00/work/ab12/tree") {
|
|
||||||
t.Errorf("the tmpfs of /tmp comes after the workspace: %v", argv)
|
|
||||||
}
|
|
||||||
// A HOME the step is given is its own.
|
|
||||||
iso.Env = append(iso.Env, "HOME=/work/ab12/home")
|
|
||||||
argv, _ = iso.Command("true")
|
|
||||||
if holds(argv, "--setenv", "HOME", "/tmp/home") || !holds(argv, "--setenv", "HOME", "/work/ab12/home") {
|
|
||||||
t.Errorf("the step's own HOME was replaced: %v", argv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnIsolationNamesNoPathItCannotPin(t *testing.T) {
|
|
||||||
for _, iso := range []Isolation{
|
|
||||||
{Workspace: ""},
|
|
||||||
{Workspace: "tree"},
|
|
||||||
{Workspace: "/"},
|
|
||||||
{Workspace: "/w", ReadOnly: []string{"relative"}},
|
|
||||||
{Workspace: "/w", Writable: []string{"/"}},
|
|
||||||
{Workspace: "/w", Caches: []string{"/a/../b"}},
|
|
||||||
{Workspace: "/w", Env: []string{"NOEQUALS"}},
|
|
||||||
} {
|
|
||||||
if _, err := iso.Command("true"); err == nil {
|
|
||||||
t.Errorf("%+v was accepted", iso)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Isolated runs every command through bwrap, with the step's environment inside and a hosts file that names only the
|
|
||||||
// loopback, removed after.
|
|
||||||
func TestIsolatedRunsEachCommandThroughBubblewrap(t *testing.T) {
|
|
||||||
fixedSystem(t)
|
|
||||||
var ran []string
|
|
||||||
var hosts string
|
|
||||||
inner := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
|
||||||
ran = append([]string{name}, args...)
|
|
||||||
for i, a := range args {
|
|
||||||
if a == "/etc/hosts" && i > 0 {
|
|
||||||
hosts = args[i-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{Stdout: "ok"}
|
|
||||||
}
|
|
||||||
r := Isolated(inner, Isolation{Workspace: "/w"})(context.Background(), []string{"A=1"}, "go", "build")
|
|
||||||
if r.Stdout != "ok" || ran[0] != Bubblewrap || !holds(ran, "--setenv", "A", "1") || !holds(ran, "--", "go", "build") {
|
|
||||||
t.Fatalf("ran %v: %+v", ran, r)
|
|
||||||
}
|
|
||||||
if hosts == "" {
|
|
||||||
t.Fatalf("no hosts file was bound: %v", ran)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,14 @@
|
|||||||
package buildstep
|
package buildstep
|
||||||
|
|
||||||
import "strings"
|
import "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
|
||||||
// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as
|
// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as
|
||||||
// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says.
|
// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says.
|
||||||
const LauncherSuffix = ".serve.mjs"
|
const LauncherSuffix = catalogue.LauncherSuffix
|
||||||
|
|
||||||
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root: for TypeScript
|
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root
|
||||||
// every entrypoint `x.js` is started by `x.serve.mjs` beside it; an entrypoint that is not JavaScript and every other
|
// (catalogue.Launchers): the one rule mesh-build-node, build-agent, the controller and a node taking in a build share
|
||||||
// language, whose build is already executable, get none. The one rule mesh-build-node, build-agent and the controller
|
// (R4e, issue 1126), so the name a build writes is the name a reader expects.
|
||||||
// share (R4e), so the name a build writes is the name a reader expects.
|
|
||||||
func Launchers(language string, entrypoints []string) map[string]string {
|
func Launchers(language string, entrypoints []string) map[string]string {
|
||||||
out := map[string]string{}
|
return catalogue.Launchers(language, entrypoints)
|
||||||
if language != "typescript" {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for _, entry := range entrypoints {
|
|
||||||
if strings.HasSuffix(entry, ".js") {
|
|
||||||
out[entry] = strings.TrimSuffix(entry, ".js") + LauncherSuffix
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MergeResult is what MergeInto made of a head and a trunk commit: the trunk commit (Base) and the head, whole, and the
|
|
||||||
// merge commit and the paths the merge changes against the trunk commit; or, when the head does not merge cleanly, the
|
|
||||||
// files in conflict, with Merge and Changed empty.
|
|
||||||
type MergeResult struct {
|
|
||||||
Base, Head, Merge string
|
|
||||||
Conflicts []string
|
|
||||||
Changed []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// MergeInto makes, in the workspace dir that FetchCommit left at commit, the merge of commit into the trunk commit
|
|
||||||
// base, as the forge's default merge would (a merge commit, never a fast-forward), and leaves the workspace at the merge
|
|
||||||
// (novox/hq ADR 0484 clause 3, ADR 0323). It is the one merge the build modules' fetch steps make, with the identity
|
|
||||||
// and flags of the controller's check merge (mesh-controller internal/builder/check_merge.go): a name nobody owns, no
|
|
||||||
// signature, no hook, nothing of the machine's git configuration that changes a merge. Nothing is pushed.
|
|
||||||
//
|
|
||||||
// A merge needs both histories, so it makes the fetch whole (the shallow clone FetchCommit made is deepened) and
|
|
||||||
// fetches base by its sha. A head that does not merge cleanly is an answer, not an error: the files in conflict, the
|
|
||||||
// merge undone and the workspace left at base. A head already in base answers a Merge equal to Base and no changed
|
|
||||||
// paths. An error is that the merge could not be tried: a base or commit that is not a full sha, no base, a workspace
|
|
||||||
// not at commit, or a fetch or git command that failed. remote and the options are those of FetchCommit; the runner's
|
|
||||||
// bound must allow a whole fetch.
|
|
||||||
func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, commit string, opts ...Option) (MergeResult, error) {
|
|
||||||
var o fetchOptions
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&o)
|
|
||||||
}
|
|
||||||
res := MergeResult{Base: base, Head: commit}
|
|
||||||
if base == "" {
|
|
||||||
return res, fmt.Errorf("buildstep: no base: a merge needs the trunk commit it is made onto")
|
|
||||||
}
|
|
||||||
if err := checkFetch(o, dir, remote, base, ""); err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
if err := checkFetch(o, dir, remote, commit, ""); err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
if err := useMirror(o, dir); err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
env := []string{"GIT_TERMINAL_PROMPT=0"}
|
|
||||||
git := func(args ...string) (string, error) {
|
|
||||||
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
|
|
||||||
r := run(ctx, env, "git", full...)
|
|
||||||
if r.Status != 0 || r.Err != "" {
|
|
||||||
return "", fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
|
|
||||||
}
|
|
||||||
return r.Stdout, nil
|
|
||||||
}
|
|
||||||
at, err := git("rev-parse", "HEAD")
|
|
||||||
if err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
if head := strings.TrimSpace(at); head != commit {
|
|
||||||
return res, fmt.Errorf("buildstep: the workspace is at %s, not at the commit %s", head, commit)
|
|
||||||
}
|
|
||||||
shallow, err := git("rev-parse", "--is-shallow-repository")
|
|
||||||
if err != nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
fetch := []string{"fetch", "--no-tags", "origin", base}
|
|
||||||
if strings.TrimSpace(shallow) == "true" {
|
|
||||||
fetch = []string{"fetch", "--no-tags", "--unshallow", "origin", base}
|
|
||||||
}
|
|
||||||
if _, err := git(fetch...); err != nil {
|
|
||||||
return res, fmt.Errorf("the trunk commit %s: %w", base, err)
|
|
||||||
}
|
|
||||||
if _, err := git("checkout", "-q", "--detach", base); err != nil {
|
|
||||||
return res, fmt.Errorf("the trunk commit %s cannot be checked out: %w", base, err)
|
|
||||||
}
|
|
||||||
message := fmt.Sprintf("Check the merge of %s into %s (made for a check, never pushed)", short(commit), short(base))
|
|
||||||
args := append(append([]string{}, mergeIdentity...), "merge", "--no-ff", "--no-edit", "--no-verify", "--quiet", "-m", message, commit)
|
|
||||||
if _, err := git(args...); err != nil {
|
|
||||||
out, _ := git("diff", "--name-only", "--diff-filter=U")
|
|
||||||
res.Conflicts = strings.Fields(out)
|
|
||||||
_, _ = git("merge", "--abort")
|
|
||||||
if len(res.Conflicts) == 0 {
|
|
||||||
return res, fmt.Errorf("the head %s could not be merged into the trunk commit %s: %w", short(commit), short(base), err)
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
merge, err := git("rev-parse", "HEAD")
|
|
||||||
if err != nil {
|
|
||||||
return res, fmt.Errorf("the merge cannot be read: %w", err)
|
|
||||||
}
|
|
||||||
res.Merge = strings.TrimSpace(merge)
|
|
||||||
changed, err := git("diff", "--name-only", "--no-renames", base, res.Merge)
|
|
||||||
if err != nil {
|
|
||||||
return res, fmt.Errorf("the paths the merge changes cannot be read: %w", err)
|
|
||||||
}
|
|
||||||
res.Changed = strings.Fields(changed)
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeIdentity is who a check's merge is made as: it is never pushed, so it names nobody's account. No hook of the
|
|
||||||
// clone's or the build machine's git configuration runs, and no setting there changes the merge (the controller's
|
|
||||||
// check merge, internal/builder/check_merge.go).
|
|
||||||
var mergeIdentity = []string{"-c", "user.name=mesh merge check", "-c", "user.email=merge-check@mesh.invalid",
|
|
||||||
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "-c", "merge.verifySignatures=false"}
|
|
||||||
|
|
||||||
func short(commit string) string {
|
|
||||||
if len(commit) > 12 {
|
|
||||||
return commit[:12]
|
|
||||||
}
|
|
||||||
return commit
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The merge on real git repositories starts git processes, so it is an integration test (the unit level keeps its bound,
|
|
||||||
// issue 895); the unit tests of merge_test.go prove the commands and the refusals with a fake runner.
|
|
||||||
|
|
||||||
func realGit(t *testing.T, dir string, args ...string) string {
|
|
||||||
t.Helper()
|
|
||||||
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeFile(t *testing.T, dir, name, content string) {
|
|
||||||
t.Helper()
|
|
||||||
p := filepath.Join(dir, name)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeRig is a local bare repository standing in for the forge, a work repository that pushes to it, and an empty
|
|
||||||
// workspace: the fetch step's shape (FetchCommit of the head into the workspace, then MergeInto).
|
|
||||||
type mergeRig struct {
|
|
||||||
t *testing.T
|
|
||||||
bare, work, ws string
|
|
||||||
fork, trunk string // the commit both histories share, and the trunk's head
|
|
||||||
remote string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMergeRig(t *testing.T) *mergeRig {
|
|
||||||
root := t.TempDir()
|
|
||||||
r := &mergeRig{t: t, bare: filepath.Join(root, "r.git"), work: filepath.Join(root, "work"), ws: filepath.Join(root, "ws")}
|
|
||||||
for _, d := range []string{r.bare, r.work, r.ws} {
|
|
||||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.remote = "file://" + r.bare
|
|
||||||
realGit(t, r.bare, "init", "-q", "--bare")
|
|
||||||
realGit(t, r.work, "init", "-q", "-b", "main")
|
|
||||||
writeFile(t, r.work, "a.txt", "one\ntwo\nthree\n")
|
|
||||||
writeFile(t, r.work, "modules/m/module.json", "{}\n")
|
|
||||||
realGit(t, r.work, "add", ".")
|
|
||||||
realGit(t, r.work, "commit", "-q", "-m", "fork point")
|
|
||||||
r.fork = realGit(t, r.work, "rev-parse", "HEAD")
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// head makes a commit on a branch off the fork point, pushes it as the pull request's head ref and answers its sha.
|
|
||||||
func (r *mergeRig) head(files map[string]string) string {
|
|
||||||
realGit(r.t, r.work, "checkout", "-q", "-B", "pr", r.fork)
|
|
||||||
for n, c := range files {
|
|
||||||
writeFile(r.t, r.work, n, c)
|
|
||||||
}
|
|
||||||
realGit(r.t, r.work, "add", ".")
|
|
||||||
realGit(r.t, r.work, "commit", "-q", "-m", "head")
|
|
||||||
h := realGit(r.t, r.work, "rev-parse", "HEAD")
|
|
||||||
realGit(r.t, r.work, "push", "-q", r.bare, "HEAD:refs/pull/1/head")
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// advance moves the trunk past the fork point with a commit changing files, pushes it and answers its sha.
|
|
||||||
func (r *mergeRig) advance(files map[string]string) string {
|
|
||||||
realGit(r.t, r.work, "checkout", "-q", "-B", "main", r.fork)
|
|
||||||
for n, c := range files {
|
|
||||||
writeFile(r.t, r.work, n, c)
|
|
||||||
}
|
|
||||||
realGit(r.t, r.work, "add", ".")
|
|
||||||
realGit(r.t, r.work, "commit", "-q", "-m", "trunk moves")
|
|
||||||
r.trunk = realGit(r.t, r.work, "rev-parse", "HEAD")
|
|
||||||
realGit(r.t, r.work, "push", "-q", "-f", r.bare, "HEAD:refs/heads/main")
|
|
||||||
return r.trunk
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mergeRig) fetch(head string) {
|
|
||||||
if err := FetchCommit(context.Background(), forgekey.ExecRunner, r.ws, r.remote, head, "refs/pull/1/head", AllowFileRemote()); err != nil {
|
|
||||||
r.t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1674/AC1: a clean merge answers the merge commit and the changed files, and leaves the workspace at the merge, which
|
|
||||||
// holds both sides' changes.
|
|
||||||
func TestMergeIntoACleanMergeAnswersTheMergeCommitAndTheChangedFiles(t *testing.T) {
|
|
||||||
r := newMergeRig(t)
|
|
||||||
head := r.head(map[string]string{"b.txt": "head\n"})
|
|
||||||
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
|
|
||||||
r.fetch(head)
|
|
||||||
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if res.Base != base || res.Head != head || len(res.Merge) != 40 || res.Merge == base || res.Merge == head || len(res.Conflicts) != 0 {
|
|
||||||
t.Fatalf("result = %+v", res)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(res.Changed, []string{"b.txt"}) {
|
|
||||||
t.Errorf("changed = %v, want only what the head adds to the trunk: b.txt", res.Changed)
|
|
||||||
}
|
|
||||||
if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != res.Merge {
|
|
||||||
t.Errorf("the workspace is at %s, not at the merge %s", at, res.Merge)
|
|
||||||
}
|
|
||||||
for f, want := range map[string]string{"b.txt": "head\n", "a.txt": "one\ntwo\nthree\nfour\n"} {
|
|
||||||
if got, _ := os.ReadFile(filepath.Join(r.ws, f)); string(got) != want {
|
|
||||||
t.Errorf("%s = %q, want %q", f, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if parents := strings.Fields(realGit(t, r.ws, "rev-list", "--parents", "-n", "1", res.Merge)); len(parents) != 3 || parents[1] != base || parents[2] != head {
|
|
||||||
t.Errorf("the merge commit's parents = %v, want the trunk commit and the head", parents)
|
|
||||||
}
|
|
||||||
if author := realGit(t, r.ws, "log", "-1", "--format=%an <%ae>", res.Merge); author != "mesh merge check <merge-check@mesh.invalid>" {
|
|
||||||
t.Errorf("the merge is made as %q", author)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1674/AC2: a conflict is an answer: the files in conflict, no merge commit, and the workspace left at the trunk commit.
|
|
||||||
func TestMergeIntoAConflictAnswersTheFilesAndLeavesTheTreeAtTheTrunk(t *testing.T) {
|
|
||||||
r := newMergeRig(t)
|
|
||||||
head := r.head(map[string]string{"a.txt": "one\nHEAD\nthree\n", "c.txt": "c\n"})
|
|
||||||
base := r.advance(map[string]string{"a.txt": "one\nTRUNK\nthree\n"})
|
|
||||||
r.fetch(head)
|
|
||||||
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("a conflict is an answer, not an error: %v", err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(res.Conflicts, []string{"a.txt"}) || res.Merge != "" || len(res.Changed) != 0 || res.Base != base || res.Head != head {
|
|
||||||
t.Fatalf("result = %+v", res)
|
|
||||||
}
|
|
||||||
if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != base {
|
|
||||||
t.Errorf("the workspace is at %s, not at the trunk commit %s", at, base)
|
|
||||||
}
|
|
||||||
if got, _ := os.ReadFile(filepath.Join(r.ws, "a.txt")); string(got) != "one\nTRUNK\nthree\n" {
|
|
||||||
t.Errorf("a.txt = %q, want the trunk's", got)
|
|
||||||
}
|
|
||||||
if st := realGit(t, r.ws, "status", "--porcelain"); st != "" {
|
|
||||||
t.Errorf("the tree is not clean after the aborted merge: %q", st)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(r.ws, "c.txt")); err == nil {
|
|
||||||
t.Error("a file of the head is in a tree left at the trunk")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A head already in the trunk merges into nothing: the merge is the trunk commit and no path changes.
|
|
||||||
func TestMergeIntoAHeadAlreadyInTheTrunkChangesNothing(t *testing.T) {
|
|
||||||
r := newMergeRig(t)
|
|
||||||
head := r.head(map[string]string{"b.txt": "head\n"})
|
|
||||||
r.fork = head // the trunk continues from the head
|
|
||||||
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
|
|
||||||
r.fetch(head)
|
|
||||||
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
|
|
||||||
if err != nil || res.Merge != base || len(res.Changed) != 0 || len(res.Conflicts) != 0 {
|
|
||||||
t.Errorf("result = %+v, %v", res, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1674/AC3: no base is refused before anything runs; so is a base or a commit that is not a full sha, a workspace that
|
|
||||||
// is not at the commit, and a remote that is not ssh://.
|
|
||||||
func TestMergeIntoRefusesWhatIsNotAMerge(t *testing.T) {
|
|
||||||
f := &fakeGit{}
|
|
||||||
for name, c := range map[string]struct{ remote, base, commit string }{
|
|
||||||
"no base": {"ssh://mesh-forge/o/r.git", "", sha},
|
|
||||||
"a short base": {"ssh://mesh-forge/o/r.git", "abc123", sha},
|
|
||||||
"a branch as base": {"ssh://mesh-forge/o/r.git", "main", sha},
|
|
||||||
"a short commit": {"ssh://mesh-forge/o/r.git", sha, "abc"},
|
|
||||||
"an https remote": {"https://forge.example/o/r.git", sha, sha},
|
|
||||||
} {
|
|
||||||
_, err := MergeInto(context.Background(), f.run, "/w", c.remote, c.base, c.commit)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("%s accepted", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(f.calls) != 0 {
|
|
||||||
t.Errorf("git ran for a refused merge: %v", f.calls)
|
|
||||||
}
|
|
||||||
g := &fakeGit{rule: func(l string) forgekey.Ran {
|
|
||||||
if strings.Contains(l, "rev-parse HEAD") {
|
|
||||||
return forgekey.Ran{Stdout: strings.Repeat("f", 40) + "\n"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{}
|
|
||||||
}}
|
|
||||||
_, err := MergeInto(context.Background(), g.run, "/w", "ssh://mesh-forge/o/r.git", strings.Repeat("a", 40), sha)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "not at the commit") || len(g.calls) != 1 {
|
|
||||||
t.Errorf("a workspace at another commit: %v after %d commands", err, len(g.calls))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1674/AC4: the merge is made with the check merge's identity and flags, hooks off, and fetches with no credential.
|
|
||||||
func TestMergeIntoRunsTheCheckMergesCommands(t *testing.T) {
|
|
||||||
base := strings.Repeat("b", 40)
|
|
||||||
f := &fakeGit{rule: func(l string) forgekey.Ran {
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(l, "rev-parse HEAD"):
|
|
||||||
return forgekey.Ran{Stdout: sha + "\n"}
|
|
||||||
case strings.Contains(l, "--is-shallow-repository"):
|
|
||||||
return forgekey.Ran{Stdout: "true\n"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{}
|
|
||||||
}}
|
|
||||||
if _, err := MergeInto(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", base, sha); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
all := strings.Join(f.calls, "\n")
|
|
||||||
for _, want := range []string{
|
|
||||||
pre + "fetch --no-tags --unshallow origin " + base,
|
|
||||||
pre + "checkout -q --detach " + base,
|
|
||||||
"user.name=mesh merge check", "user.email=merge-check@mesh.invalid", "commit.gpgsign=false", "core.hooksPath=/dev/null",
|
|
||||||
"merge --no-ff --no-edit --no-verify --quiet -m",
|
|
||||||
pre + "diff --name-only --no-renames " + base,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(all, want) {
|
|
||||||
t.Errorf("the commands lack %q:\n%s", want, all)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, c := range f.calls {
|
|
||||||
if strings.Contains(c, "https://") || !strings.Contains(c, "credential.helper= ") {
|
|
||||||
t.Errorf("command %q carries a credential or https", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Issue 890 C1 (AC3): a mirror brought to two commits in turn; a fetch through it checks out each commit, the
|
|
||||||
// alternates name the mirror, and no symlink is in the workspace.
|
|
||||||
func TestMirrorServesTwoCommitsWithoutSymlinks(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
src := filepath.Join(root, "src.git")
|
|
||||||
work := filepath.Join(root, "work")
|
|
||||||
for _, d := range []string{src, work} {
|
|
||||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gitIn(t, src, "init", "-q", "--bare")
|
|
||||||
gitIn(t, work, "init", "-q")
|
|
||||||
var commits []string
|
|
||||||
for _, c := range []string{"one", "two"} {
|
|
||||||
if err := os.WriteFile(filepath.Join(work, "f"), []byte(c), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
gitIn(t, work, "add", "f")
|
|
||||||
gitIn(t, work, "commit", "-q", "-m", c)
|
|
||||||
commits = append(commits, gitIn(t, work, "rev-parse", "HEAD"))
|
|
||||||
gitIn(t, work, "push", "-q", src, "HEAD:refs/heads/main")
|
|
||||||
}
|
|
||||||
remote := "file://" + src
|
|
||||||
mirror := filepath.Join(root, "mirror.git")
|
|
||||||
for i, commit := range commits {
|
|
||||||
if err := UpdateMirror(context.Background(), forgekey.ExecRunner, mirror, remote, commit, "", AllowFileRemote()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ws := filepath.Join(root, "ws"+string(rune('0'+i)))
|
|
||||||
if err := os.MkdirAll(ws, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := FetchCommit(context.Background(), forgekey.ExecRunner, ws, remote, commit, "", AllowFileRemote(), WithMirror(mirror)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if head := gitIn(t, ws, "rev-parse", "HEAD"); head != commit {
|
|
||||||
t.Errorf("HEAD = %s, want %s", head, commit)
|
|
||||||
}
|
|
||||||
b, _ := os.ReadFile(filepath.Join(ws, ".git", "objects", "info", "alternates"))
|
|
||||||
if !strings.Contains(string(b), filepath.Join(mirror, "objects")) {
|
|
||||||
t.Errorf("alternates %q", b)
|
|
||||||
}
|
|
||||||
_ = filepath.Walk(ws, func(p string, fi os.FileInfo, err error) error {
|
|
||||||
if err == nil && fi.Mode()&os.ModeSymlink != 0 {
|
|
||||||
t.Errorf("symlink %s", p)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Issue 890 C1 (ADR 0536 §2): a fetch through a bare mirror names the mirror in .git/objects/info/alternates, a
|
|
||||||
// plain file, and never links it.
|
|
||||||
func TestFetchCommitWithMirrorWritesAlternates(t *testing.T) {
|
|
||||||
mirror := t.TempDir()
|
|
||||||
ws := t.TempDir()
|
|
||||||
f := &fakeGit{}
|
|
||||||
// the fake's init does not create .git; make the objects directory as git init would.
|
|
||||||
if err := os.MkdirAll(filepath.Join(ws, ".git", "objects", "info"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
err := FetchCommit(context.Background(), f.run, ws, "ssh://mesh-forge/novox/mesh-sdk.git", sha, "", WithMirror(mirror))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
p := filepath.Join(ws, ".git", "objects", "info", "alternates")
|
|
||||||
b, err := os.ReadFile(p)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, want := strings.TrimSpace(string(b)), filepath.Join(mirror, "objects"); got != want {
|
|
||||||
t.Errorf("alternates %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
if fi, err := os.Lstat(p); err != nil || !fi.Mode().IsRegular() {
|
|
||||||
t.Errorf("alternates is not a plain file: %v %v", fi, err)
|
|
||||||
}
|
|
||||||
// the alternates exist before the fetch runs
|
|
||||||
if len(f.calls) < 3 || !strings.Contains(f.calls[2], "fetch") {
|
|
||||||
t.Errorf("calls %v", f.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithMirrorRefusesARelativeOrMissingMirror(t *testing.T) {
|
|
||||||
for _, m := range []string{"rel/mirror", filepath.Join(t.TempDir(), "absent")} {
|
|
||||||
f := &fakeGit{}
|
|
||||||
err := FetchCommit(context.Background(), f.run, t.TempDir(), "ssh://mesh-forge/novox/x.git", sha, "", WithMirror(m))
|
|
||||||
if err == nil || len(f.calls) != 0 {
|
|
||||||
t.Errorf("mirror %q: err %v, calls %v", m, err, f.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateMirrorInitsBareThenFetches(t *testing.T) {
|
|
||||||
mirror := filepath.Join(t.TempDir(), "mirror.git")
|
|
||||||
f := &fakeGit{}
|
|
||||||
if err := UpdateMirror(context.Background(), f.run, mirror, "ssh://mesh-forge/novox/x.git", sha, ""); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
pm := "git -c credential.helper= -C " + mirror + " "
|
|
||||||
want := []string{
|
|
||||||
"git -c credential.helper= init -q --bare " + mirror,
|
|
||||||
pm + "remote add origin ssh://mesh-forge/novox/x.git",
|
|
||||||
pm + "fetch --no-tags origin " + sha,
|
|
||||||
pm + "cat-file -e " + sha + "^{commit}",
|
|
||||||
}
|
|
||||||
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
|
|
||||||
t.Errorf("commands:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateMirrorKeepsAnExistingMirrorAndFallsBackToTheRef(t *testing.T) {
|
|
||||||
mirror := t.TempDir() // exists: no init
|
|
||||||
if err := os.WriteFile(filepath.Join(mirror, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
f := &fakeGit{}
|
|
||||||
f.rule = func(args string) (r forgekey.Ran) {
|
|
||||||
if strings.HasSuffix(args, "origin "+sha) {
|
|
||||||
r.Status = 1
|
|
||||||
r.Stderr = "not our ref"
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := UpdateMirror(context.Background(), f.run, mirror, "ssh://mesh-forge/novox/x.git", sha, "refs/pull/3/head"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
all := strings.Join(f.calls, "\n")
|
|
||||||
if strings.Contains(all, "init") || strings.Contains(all, "remote add") || !strings.Contains(all, "fetch --no-tags origin refs/pull/3/head") {
|
|
||||||
t.Errorf("commands:\n%s", all)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateMirrorRefusesBeforeRunning(t *testing.T) {
|
|
||||||
f := &fakeGit{}
|
|
||||||
for _, c := range []struct{ remote, commit string }{
|
|
||||||
{"https://x/y.git", sha}, {"ssh://u@h/o/r.git", sha}, {"ssh://h/o/r.git", "abc"},
|
|
||||||
} {
|
|
||||||
if err := UpdateMirror(context.Background(), f.run, t.TempDir(), c.remote, c.commit, ""); err == nil {
|
|
||||||
t.Errorf("%v accepted", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(f.calls) != 0 {
|
|
||||||
t.Errorf("ran %v", f.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// narrowKept are the files of a module's directory a narrowed build keeps beside its named paths: its manifest and
|
|
||||||
// its lock files, each an input of the module's key (ModuleTree's Manifest and Locks). package.json and go.mod are
|
|
||||||
// not kept: neither is in the named tree, so a build that needs one names it among its artifact's paths, which puts
|
|
||||||
// it in the key (novox/hq ADR 0499 clause 4).
|
|
||||||
var narrowKept = append([]string{"module.json"}, lockFiles...)
|
|
||||||
|
|
||||||
// Narrow leaves in the fetched tree only what a build of an artifact that names its paths may see (novox/hq ADR 0499
|
|
||||||
// clause 4): r.Dir's named paths, its manifest and lock files, and .git; everything else is removed, so a file outside
|
|
||||||
// them cannot change what is built without changing its key. A named path absent at the tree is an error naming it,
|
|
||||||
// and nothing is removed then. With no paths in r it changes nothing. Both build modules' fetch steps call it.
|
|
||||||
func Narrow(tree string, r FetchRequest) error {
|
|
||||||
if len(r.Paths) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
keep := map[string]bool{".git": true}
|
|
||||||
for _, p := range r.Paths {
|
|
||||||
rel := path.Join(r.Dir, p)
|
|
||||||
if strings.HasPrefix(rel, "../") || rel == ".." || path.IsAbs(p) {
|
|
||||||
return fmt.Errorf("the named path %q leaves the module's directory", p)
|
|
||||||
}
|
|
||||||
if _, err := os.Lstat(filepath.Join(tree, filepath.FromSlash(rel))); err != nil {
|
|
||||||
return fmt.Errorf("the named path %q is not in the fetched tree: %w", p, err)
|
|
||||||
}
|
|
||||||
keep[rel] = true
|
|
||||||
}
|
|
||||||
for _, f := range narrowKept {
|
|
||||||
keep[path.Join(r.Dir, f)] = true
|
|
||||||
}
|
|
||||||
return prune(tree, "", keep)
|
|
||||||
}
|
|
||||||
|
|
||||||
// prune removes from dir (rel within the tree) every entry that is neither kept nor on the way to one.
|
|
||||||
func prune(root, rel string, keep map[string]bool) error {
|
|
||||||
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(rel)))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
p := path.Join(rel, e.Name())
|
|
||||||
if keep[p] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ancestor := false
|
|
||||||
for k := range keep {
|
|
||||||
if strings.HasPrefix(k, p+"/") {
|
|
||||||
ancestor = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ancestor && e.IsDir() {
|
|
||||||
if err := prune(root, p, keep); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := os.RemoveAll(filepath.Join(root, filepath.FromSlash(p))); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func filesUnder(t *testing.T, root string) []string {
|
|
||||||
t.Helper()
|
|
||||||
var out []string
|
|
||||||
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
|
||||||
if err == nil && !info.IsDir() {
|
|
||||||
rel, _ := filepath.Rel(root, p)
|
|
||||||
out = append(out, filepath.ToSlash(rel))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// A build of an artifact that names its paths sees only those paths, its manifest and its lock files (novox/hq ADR 0499
|
|
||||||
// clause 4): Narrow removes everything else of the fetched tree but .git, package.json and go.mod among it, since
|
|
||||||
// neither is in the named tree nor among the lock files; with no paths it changes nothing.
|
|
||||||
func TestNarrowLeavesOnlyTheNamedPathsTheManifestAndTheLocks(t *testing.T) {
|
|
||||||
tree := t.TempDir()
|
|
||||||
for _, f := range []string{".git/HEAD", "module.json", "package.json", "package-lock.json", "go.sum", "src/index.ts",
|
|
||||||
"src/deep/a.ts", "test/a.test.ts", "tsconfig.json", "go/delivery/x.go", "README.md", "other/module.json"} {
|
|
||||||
writeTreeFile(t, tree, f, "x")
|
|
||||||
}
|
|
||||||
if err := Narrow(tree, FetchRequest{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := len(filesUnder(t, tree)); got != 12 {
|
|
||||||
t.Fatalf("a request without paths changed the tree: %d files", got)
|
|
||||||
}
|
|
||||||
if err := Narrow(tree, FetchRequest{Dir: ".", Paths: []string{"src", "test", "tsconfig.json"}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := ".git/HEAD,go.sum,module.json,package-lock.json,src/deep/a.ts,src/index.ts,test/a.test.ts,tsconfig.json"
|
|
||||||
if got := strings.Join(filesUnder(t, tree), ","); got != want {
|
|
||||||
t.Fatalf("the narrowed tree holds %s\nwant %s", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNarrowKeepsAModuleDirectorysPathsAndFailsOnAnAbsentOne(t *testing.T) {
|
|
||||||
tree := t.TempDir()
|
|
||||||
for _, f := range []string{".git/config", "modules/a/module.json", "modules/a/go.mod", "modules/a/src/x.ts",
|
|
||||||
"modules/a/notes.md", "modules/b/module.json", "root.txt"} {
|
|
||||||
writeTreeFile(t, tree, f, "x")
|
|
||||||
}
|
|
||||||
err := Narrow(tree, FetchRequest{Dir: "modules/a", Paths: []string{"src", "gone"}})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), `"gone"`) {
|
|
||||||
t.Fatalf("an absent named path gave %v", err)
|
|
||||||
}
|
|
||||||
if len(filesUnder(t, tree)) != 7 {
|
|
||||||
t.Fatal("a refused narrowing removed files")
|
|
||||||
}
|
|
||||||
if err := Narrow(tree, FetchRequest{Dir: "modules/a", Paths: []string{"src"}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := strings.Join(filesUnder(t, tree), ","); got != ".git/config,modules/a/module.json,modules/a/src/x.ts" {
|
|
||||||
t.Fatalf("the narrowed tree holds %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A build that needs package.json or go.mod names it among its paths, and so keys on it (ADR 0499 clause 4).
|
|
||||||
func TestNarrowKeepsAManifestOfTheLanguageOnlyWhenNamed(t *testing.T) {
|
|
||||||
tree := t.TempDir()
|
|
||||||
for _, f := range []string{"module.json", "package.json", "go.mod", "src/a.ts"} {
|
|
||||||
writeTreeFile(t, tree, f, "x")
|
|
||||||
}
|
|
||||||
if err := Narrow(tree, FetchRequest{Dir: ".", Paths: []string{"src", "package.json"}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := strings.Join(filesUnder(t, tree), ","); got != "module.json,package.json,src/a.ts" {
|
|
||||||
t.Fatalf("the narrowed tree holds %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pack tars and gzips a directory the way the build agent does, byte for byte, and answers the archive and its
|
|
||||||
// digest, "sha256:" and the hex of the archive's bytes (issue 1479; novox/hq ADR 0405 §7, ADR 0453 §7). It is a
|
|
||||||
// copy of mesh-controller's internal/builder/builder.go pack (origin/main 073ccc29, :1351-1425), which stays
|
|
||||||
// where it is until the build agent goes: a build module's digest is compared with the build agent's for one
|
|
||||||
// fingerprint, so the bytes must be equal.
|
|
||||||
//
|
|
||||||
// Deterministically: regular files only (a symlink or a device is refused), sorted relative paths with
|
|
||||||
// slashes, each header carrying its name, permission bits and size and nothing else, the gzip default writer.
|
|
||||||
func Pack(root string) (archive []byte, digest string, err error) {
|
|
||||||
raw, err := pack(root)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256(raw)
|
|
||||||
return raw, "sha256:" + hex.EncodeToString(sum[:]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func pack(root string) ([]byte, error) {
|
|
||||||
info, err := os.Stat(root)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return nil, fmt.Errorf("%s is not a directory", root)
|
|
||||||
}
|
|
||||||
|
|
||||||
var paths []string
|
|
||||||
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if info.IsDir() || !info.Mode().IsRegular() {
|
|
||||||
// Only files. A symlink or a device in an archive is refused by the host that unpacks
|
|
||||||
// it, so putting one in would build something that cannot be applied.
|
|
||||||
if !info.IsDir() && !info.Mode().IsRegular() {
|
|
||||||
return fmt.Errorf("%s is neither a file nor a directory, and an archive carries "+
|
|
||||||
"only those", path)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
paths = append(paths, path)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// filepath.Walk walks in lexical order; the sort is kept as the build agent keeps it, since an archive
|
|
||||||
// whose digest changes because the traversal did is a silent failure.
|
|
||||||
sort.Strings(paths)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
zipped := gzip.NewWriter(&out)
|
|
||||||
writer := tar.NewWriter(zipped)
|
|
||||||
for _, path := range paths {
|
|
||||||
body, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
relative, err := filepath.Rel(root, path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
mode := int64(info.Mode().Perm())
|
|
||||||
if err := writer.WriteHeader(&tar.Header{
|
|
||||||
Name: filepath.ToSlash(relative), Mode: mode, Size: int64(len(body)),
|
|
||||||
Typeflag: tar.TypeReg,
|
|
||||||
// Everything else left at its zero value on purpose: no times, owners or names.
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := writer.Write(body); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := writer.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := zipped.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out.Bytes(), nil
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fixture writes three files in two directories (modes 0644 and 0755).
|
|
||||||
func fixture(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
root := t.TempDir()
|
|
||||||
for _, f := range []struct {
|
|
||||||
p string
|
|
||||||
mode os.FileMode
|
|
||||||
body string
|
|
||||||
}{{"a/x.txt", 0o644, "x\n"}, {"a/run.sh", 0o755, "#!/bin/sh\necho hi\n"}, {"b/c/y.txt", 0o644, "why\n"}} {
|
|
||||||
path := filepath.Join(root, f.p)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte(f.body), f.mode); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.Chmod(path, f.mode); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1479/AC1: the tar stream inside the archive equals the bytes mesh-controller's pack makes for this tree.
|
|
||||||
// testdata/pack-golden.tar is the uncompressed stream, made once by running mesh-controller
|
|
||||||
// internal/builder/builder.go pack (origin/main 073ccc29, :1351-1425) on this fixture (above, modes set by chmod)
|
|
||||||
// and gunzipping its archive. The gzip layer is not compared byte for byte, since compress/gzip's output differs
|
|
||||||
// between Go versions; its header is checked to carry no name, no comment and no time.
|
|
||||||
func TestPackEqualsTheBuildAgentsArchive(t *testing.T) {
|
|
||||||
golden, err := os.ReadFile("testdata/pack-golden.tar")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
archive, digest, err := Pack(fixture(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
zr, err := gzip.NewReader(bytes.NewReader(archive))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if zr.Name != "" || zr.Comment != "" || !zr.ModTime.IsZero() {
|
|
||||||
t.Errorf("gzip header = name %q, comment %q, time %v; want none", zr.Name, zr.Comment, zr.ModTime)
|
|
||||||
}
|
|
||||||
tarBytes, err := io.ReadAll(zr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(tarBytes, golden) {
|
|
||||||
t.Errorf("the tar stream is %d bytes, the build agent's %d, and they differ", len(tarBytes), len(golden))
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256(archive)
|
|
||||||
if digest != "sha256:"+hex.EncodeToString(sum[:]) {
|
|
||||||
t.Errorf("digest = %s, not the sha256 of the archive", digest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1479/AC2: touching every file's time changes nothing.
|
|
||||||
func TestPackDigestIgnoresFileTimes(t *testing.T) {
|
|
||||||
root := fixture(t)
|
|
||||||
_, first, err := Pack(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
later := time.Now().Add(48 * time.Hour)
|
|
||||||
filepath.Walk(root, func(p string, _ os.FileInfo, _ error) error { return os.Chtimes(p, later, later) })
|
|
||||||
if _, second, err := Pack(root); err != nil || second != first {
|
|
||||||
t.Errorf("digests %s and %s (%v)", first, second, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
//go:build unix
|
|
||||||
|
|
||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1479/AC3: anything but a file or a directory is refused, naming it. The rule of this repository is that no
|
|
||||||
// test makes a symlink, so the non-regular entry is a named pipe: Pack refuses a symlink on the same branch
|
|
||||||
// (filepath.Walk reports it by lstat as not regular).
|
|
||||||
func TestPackRefusesAnEntryThatIsNotAFile(t *testing.T) {
|
|
||||||
root := fixture(t)
|
|
||||||
pipe := filepath.Join(root, "a", "pipe")
|
|
||||||
if err := syscall.Mkfifo(pipe, 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, _, err := Pack(root)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), pipe) {
|
|
||||||
t.Errorf("Pack = %v, want a refusal naming %s", err, pipe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **The pull loop the build modules share** (novox/hq ADR 0525 §4-§6, ADR 0536 §4; issue 1887). A build module on a node
|
|
||||||
// whose build capacity is above zero pulls the asks of its language's seat: from the delivery worker first, then check,
|
|
||||||
// then recheck, holding at most its capacity at once. Each ask runs as a step under the ask's own id, so its end is the
|
|
||||||
// key <node>.<ask id> in the module's steps state, which the asker watches; the ask is acknowledged when that end is
|
|
||||||
// written, kept alive while it runs by an in-progress signal, and redelivered by the stream to another node if this one
|
|
||||||
// stops. No part keeps a timer for an ask: the pull's own wait and the stream's acknowledgement wait are the bus's.
|
|
||||||
|
|
||||||
// Pulled is one ask a source handed over, with what decides its fate on the bus.
|
|
||||||
type Pulled struct {
|
|
||||||
// Kind is the worker it came from, filled by the puller.
|
|
||||||
Kind string
|
|
||||||
Data []byte
|
|
||||||
// Delivered is how many times the bus has handed it over, this time included.
|
|
||||||
Delivered uint64
|
|
||||||
// Ack ends it: it is never delivered again. InProgress tells the bus it is still being worked, restarting its
|
|
||||||
// acknowledgement wait. Nak hands it back to be delivered again at once (to this node or another). Term drops it.
|
|
||||||
Ack, InProgress, Nak, Term func() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// AskSource is where a puller takes asks from: the seat's workers on the bus (JetStreamAsks) or a test double
|
|
||||||
// (FakeAsks).
|
|
||||||
type AskSource interface {
|
|
||||||
// Next takes one ask of kind: with wait 0 only one already waiting, else waiting for one at most wait. It answers
|
|
||||||
// nil and no error when none came.
|
|
||||||
Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defaults of a Puller.
|
|
||||||
var (
|
|
||||||
// DefaultPullWait is how long one pull of the delivery worker waits for an ask while every worker is empty: the
|
|
||||||
// pull's own wait (as process.Consume's), after which the check and recheck workers are looked at again.
|
|
||||||
DefaultPullWait = 5 * time.Second
|
|
||||||
// DefaultHeartbeat is how often a running ask tells the bus it is still being worked; under the seat worker's
|
|
||||||
// acknowledgement wait of 60 s (mesh-controller internal/broker HolderConsumerFor).
|
|
||||||
DefaultHeartbeat = 20 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// Puller pulls a build seat's asks and runs them.
|
|
||||||
type Puller struct {
|
|
||||||
Source AskSource
|
|
||||||
Language string // the seat's language: delivery.AskGo, AskNode or AskImage
|
|
||||||
Node string // the node this holder runs on, the first part of its step keys
|
|
||||||
Put Put // writes the module's steps state
|
|
||||||
// Capacity is how many asks the node runs at once; read before every pull, so a change takes effect at the next
|
|
||||||
// one. 0 pulls nothing.
|
|
||||||
Capacity func() int
|
|
||||||
// Run runs one ask's job; a *Refusal ends its step refused. Its result is the step's result.
|
|
||||||
Run func(ctx context.Context, ask delivery.StepAsk) (any, error)
|
|
||||||
// PullWait and Heartbeat default to DefaultPullWait and DefaultHeartbeat.
|
|
||||||
PullWait time.Duration
|
|
||||||
Heartbeat time.Duration
|
|
||||||
Logf func(format string, args ...any)
|
|
||||||
// Load reads one key of the module's steps state (nil and no error when it is absent). When set, Pull reads the
|
|
||||||
// node's pause key (delivery.PauseKey) before it pulls, and Pause and Resume write it with Put, so a pause outlives
|
|
||||||
// a restart.
|
|
||||||
Load func(key string) ([]byte, error)
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
inHand int
|
|
||||||
running map[string]RunningAsk
|
|
||||||
paused bool
|
|
||||||
wake chan struct{}
|
|
||||||
once sync.Once
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Puller) init() {
|
|
||||||
p.once.Do(func() { p.wake = make(chan struct{}, 1) })
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Puller) logf(format string, args ...any) {
|
|
||||||
if p.Logf != nil {
|
|
||||||
p.Logf(format, args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wake makes a waiting puller look again: after a capacity change, a resume, or a step's end.
|
|
||||||
func (p *Puller) Wake() {
|
|
||||||
p.init()
|
|
||||||
select {
|
|
||||||
case p.wake <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pause stops the puller taking asks; the asks it holds run to their end. by names who paused it. With Load set the
|
|
||||||
// pause is written on the node's pause key first, and an error there leaves the puller as it was.
|
|
||||||
func (p *Puller) Pause(by string) error { return p.setPaused(true, by) }
|
|
||||||
|
|
||||||
// Resume lets the puller take asks again, written as Pause's is.
|
|
||||||
func (p *Puller) Resume(by string) error { return p.setPaused(false, by) }
|
|
||||||
|
|
||||||
func (p *Puller) setPaused(paused bool, by string) error {
|
|
||||||
if p.Load != nil {
|
|
||||||
key, err := delivery.PauseKey(p.Node)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
raw, err := delivery.EncodePause(delivery.Pause{Paused: paused, By: by, At: now()})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := p.Put(key, json.RawMessage(raw)); err != nil {
|
|
||||||
return fmt.Errorf("buildstep: writing the pause of %s: %w", p.Node, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
|
||||||
p.paused = paused
|
|
||||||
p.mu.Unlock()
|
|
||||||
p.Wake()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// restore reads the node's pause key, when Load is set.
|
|
||||||
func (p *Puller) restore() error {
|
|
||||||
if p.Load == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
key, err := delivery.PauseKey(p.Node)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
raw, err := p.Load(key)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("buildstep: reading the pause of %s: %w", p.Node, err)
|
|
||||||
}
|
|
||||||
if raw == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
v, err := delivery.DecodePause(raw)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("buildstep: the pause of %s: %w", p.Node, err)
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
|
||||||
p.paused = v.Paused
|
|
||||||
p.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paused says whether the puller takes no asks.
|
|
||||||
func (p *Puller) Paused() bool {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
return p.paused
|
|
||||||
}
|
|
||||||
|
|
||||||
// InHand is how many asks run now.
|
|
||||||
func (p *Puller) InHand() int {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
return p.inHand
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunningAsk is an ask a puller runs now, as its seat's current verb says it.
|
|
||||||
type RunningAsk struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Module string `json:"module,omitempty"`
|
|
||||||
Started time.Time `json:"started"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Running are the asks the puller runs now, oldest first.
|
|
||||||
func (p *Puller) Running() []RunningAsk {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
out := make([]RunningAsk, 0, len(p.running))
|
|
||||||
for _, r := range p.running {
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// room says whether the puller may take one more ask now.
|
|
||||||
func (p *Puller) room() bool {
|
|
||||||
capacity := 0
|
|
||||||
if p.Capacity != nil {
|
|
||||||
capacity = p.Capacity()
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
return !p.paused && p.inHand < capacity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pull takes and runs asks until ctx ends. An ask still running then is neither ended nor acknowledged: the stream
|
|
||||||
// delivers it again, to another node or to this one restarted.
|
|
||||||
func (p *Puller) Pull(ctx context.Context) error {
|
|
||||||
p.init()
|
|
||||||
if p.Source == nil || p.Put == nil || p.Run == nil {
|
|
||||||
return errors.New("buildstep: a puller needs its source, its steps' put and its run")
|
|
||||||
}
|
|
||||||
if err := p.restore(); err != nil {
|
|
||||||
return err // pulling on a pause that cannot be read could build on a node paused on purpose
|
|
||||||
}
|
|
||||||
wait := p.PullWait
|
|
||||||
if wait <= 0 {
|
|
||||||
wait = DefaultPullWait
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !p.room() {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-p.wake:
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
msg, err := p.next(ctx, wait)
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
p.logf("the %s asks cannot be pulled: %v", p.Language, err)
|
|
||||||
select { // the pull's own wait before the next try, as an empty pull's
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-p.wake:
|
|
||||||
case <-time.After(wait):
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if msg == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.take(ctx, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// next is one ask from the workers in their order: one already waiting on delivery, check or recheck, and when none
|
|
||||||
// waits, a pull of the delivery worker that waits for one.
|
|
||||||
func (p *Puller) next(ctx context.Context, wait time.Duration) (*Pulled, error) {
|
|
||||||
for _, kind := range delivery.AskKinds {
|
|
||||||
msg, err := p.Source.Next(ctx, kind, 0)
|
|
||||||
if err != nil || msg != nil {
|
|
||||||
return withKind(msg, kind), err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg, err := p.Source.Next(ctx, delivery.AskDelivery, wait)
|
|
||||||
return withKind(msg, delivery.AskDelivery), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func withKind(msg *Pulled, kind string) *Pulled {
|
|
||||||
if msg != nil {
|
|
||||||
msg.Kind = kind
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
// take starts one ask: it decodes it, refuses one that is not of this seat, and runs it as a step under its own id.
|
|
||||||
func (p *Puller) take(ctx context.Context, msg *Pulled) {
|
|
||||||
kind := msg.Kind
|
|
||||||
ask, err := delivery.DecodeStepAsk(msg.Data)
|
|
||||||
if err == nil && (ask.Language != p.Language || (kind != "" && ask.Kind != kind)) {
|
|
||||||
err = fmt.Errorf("the ask %s is a %s ask of %s, pulled from the %s %s worker", ask.ID, ask.Kind, ask.Language, p.Language, kind)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
// An ask that is not one runs nowhere: delivering it again would only fail again.
|
|
||||||
p.logf("an ask dropped: %v", err)
|
|
||||||
_ = msg.Term()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID)
|
|
||||||
if err != nil {
|
|
||||||
p.logf("the ask %s cannot start on %s: %v", ask.ID, p.Node, err)
|
|
||||||
_ = msg.Nak()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if msg.Delivered > delivery.AskRuns {
|
|
||||||
// Run AskRuns times and never ended: each run took its node down or died with it. It is run no more, and said
|
|
||||||
// (issue 1907): the asker's key fails with why.
|
|
||||||
why := fmt.Errorf("the ask was taken %d times and no run of it ended: each took its build node down or died with it, so it is run no more (poison)", msg.Delivered-1)
|
|
||||||
p.logf("the ask %s is ended on %s: %v", ask.ID, p.Node, why)
|
|
||||||
_ = finish(p.Put, key, step, nil, why, p.Logf) // run no more either way: the asker's bound says it then
|
|
||||||
_ = msg.Term()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
module := ""
|
|
||||||
if ask.Build != nil {
|
|
||||||
module = ask.Build.Module
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
|
||||||
p.inHand++
|
|
||||||
if p.running == nil {
|
|
||||||
p.running = map[string]RunningAsk{}
|
|
||||||
}
|
|
||||||
p.running[ask.ID] = RunningAsk{ID: ask.ID, Kind: ask.Kind, Module: module, Started: step.Started}
|
|
||||||
p.mu.Unlock()
|
|
||||||
go p.run(ctx, msg, ask, key, step)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step) {
|
|
||||||
defer func() {
|
|
||||||
p.mu.Lock()
|
|
||||||
p.inHand--
|
|
||||||
delete(p.running, ask.ID)
|
|
||||||
p.mu.Unlock()
|
|
||||||
p.Wake()
|
|
||||||
}()
|
|
||||||
beat := p.Heartbeat
|
|
||||||
if beat <= 0 {
|
|
||||||
beat = DefaultHeartbeat
|
|
||||||
}
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
t := time.NewTicker(beat)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
if msg.InProgress != nil {
|
|
||||||
_ = msg.InProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
result, err := protect(ctx, func(ctx context.Context) (any, error) { return p.Run(ctx, ask) })
|
|
||||||
close(done)
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
// The process stops: the ask is neither ended nor acknowledged here, so the stream gives it to a node that runs.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ferr := finish(p.Put, key, step, result, err, p.Logf); ferr != nil {
|
|
||||||
// No end was written: the ask is not acknowledged as done, so the stream gives it again and a run that can
|
|
||||||
// write its end (or the poison rule, issue 1907) ends it where its asker sees it (issue 1930).
|
|
||||||
p.logf("the ask %s ran on %s and no end of it could be written, so it is given back: %v", ask.ID, p.Node, ferr)
|
|
||||||
_ = msg.Nak()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if aerr := msg.Ack(); aerr != nil {
|
|
||||||
p.logf("the ask %s ended on %s and its acknowledgement failed: %v", ask.ID, p.Node, aerr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go/jetstream"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JetStreamAsks is a build seat's workers on the bus: the shared pull consumer of each kind on the seat's stream
|
|
||||||
// (delivery.AskStream, AskWorker), which the broker makes when the seat is declared; a holder only binds them.
|
|
||||||
type JetStreamAsks struct {
|
|
||||||
JS jetstream.JetStream
|
|
||||||
Language string
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
cons map[string]jetstream.Consumer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *JetStreamAsks) consumer(ctx context.Context, kind string) (jetstream.Consumer, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if c, ok := s.cons[kind]; ok {
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
stream, err := delivery.AskStream(s.Language)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
worker, err := delivery.AskWorker(s.Language, kind)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c, err := s.JS.Consumer(ctx, stream, worker)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("binding the %s worker %s on %s: %w", kind, worker, stream, err)
|
|
||||||
}
|
|
||||||
if s.cons == nil {
|
|
||||||
s.cons = map[string]jetstream.Consumer{}
|
|
||||||
}
|
|
||||||
s.cons[kind] = c
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next is AskSource's: a fetch of one with no wait when wait is 0, else one that waits at most wait.
|
|
||||||
func (s *JetStreamAsks) Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error) {
|
|
||||||
c, err := s.consumer(ctx, kind)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var batch jetstream.MessageBatch
|
|
||||||
if wait <= 0 {
|
|
||||||
batch, err = c.FetchNoWait(1)
|
|
||||||
} else {
|
|
||||||
batch, err = c.Fetch(1, jetstream.FetchMaxWait(wait))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case msg, ok := <-batch.Messages():
|
|
||||||
if !ok {
|
|
||||||
return nil, batch.Error()
|
|
||||||
}
|
|
||||||
var delivered uint64 = 1
|
|
||||||
if meta, err := msg.Metadata(); err == nil && meta != nil {
|
|
||||||
delivered = meta.NumDelivered
|
|
||||||
}
|
|
||||||
return &Pulled{Data: append([]byte(nil), msg.Data()...), Delivered: delivered,
|
|
||||||
Ack: msg.Ack, InProgress: msg.InProgress, Nak: msg.Nak, Term: msg.Term}, nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeclareAskWorkers makes language's seat stream and its three workers on a bus with no broker: a test's (the build
|
|
||||||
// modules' integration tests start one). On the mesh the broker declares them from the seat, as it declares every
|
|
||||||
// seat's stream (mesh-controller internal/broker SeatStreams); a holder never does.
|
|
||||||
func DeclareAskWorkers(ctx context.Context, js jetstream.JetStream, language string, ackWait time.Duration) error {
|
|
||||||
stream, err := delivery.AskStream(language)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
seat, _ := delivery.BuildSeat(language)
|
|
||||||
if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{"mesh.seat." + seat + ".accept.>"},
|
|
||||||
Retention: jetstream.WorkQueuePolicy}); err != nil {
|
|
||||||
return fmt.Errorf("the stream %s: %w", stream, err)
|
|
||||||
}
|
|
||||||
for _, kind := range delivery.AskKinds {
|
|
||||||
worker, _ := delivery.AskWorker(language, kind)
|
|
||||||
subject, _ := delivery.AskSubject(language, kind)
|
|
||||||
if _, err := js.CreateOrUpdateConsumer(ctx, stream, jetstream.ConsumerConfig{Durable: worker, FilterSubject: subject,
|
|
||||||
AckPolicy: jetstream.AckExplicitPolicy, AckWait: ackWait, MaxDeliver: 5}); err != nil {
|
|
||||||
return fmt.Errorf("the worker %s: %w", worker, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FakeAsks is a test double of a seat's workers for the build modules' unit tests: Publish puts an ask on a kind's
|
|
||||||
// worker, Next hands them out in their order, and the fates the puller gives them are counted. Safe for concurrent use.
|
|
||||||
type FakeAsks struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
waiting map[string][][]byte
|
|
||||||
added chan struct{}
|
|
||||||
// Acked, Termed and Naked are the asks' ids or data by fate, InProgress how many heartbeats were given.
|
|
||||||
Acked, Termed, Naked []string
|
|
||||||
InProgress int
|
|
||||||
// Delivered, when set, is the delivery count every ask handed over says (1 when 0): a test of a poisoned ask sets it.
|
|
||||||
Delivered uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFakeAsks is an empty double.
|
|
||||||
func NewFakeAsks() *FakeAsks {
|
|
||||||
return &FakeAsks{waiting: map[string][][]byte{}, added: make(chan struct{}, 1)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish puts an encoded ask on kind's worker.
|
|
||||||
func (f *FakeAsks) Publish(kind string, data []byte) {
|
|
||||||
f.mu.Lock()
|
|
||||||
f.waiting[kind] = append(f.waiting[kind], data)
|
|
||||||
f.mu.Unlock()
|
|
||||||
select {
|
|
||||||
case f.added <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PublishAsk encodes a and puts it on its kind's worker.
|
|
||||||
func (f *FakeAsks) PublishAsk(a delivery.StepAsk) error {
|
|
||||||
raw, err := delivery.EncodeStepAsk(a)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
f.Publish(a.Kind, raw)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Waiting is how many asks wait on kind's worker.
|
|
||||||
func (f *FakeAsks) Waiting(kind string) int {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
return len(f.waiting[kind])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fates is a copy of the acked, termed and naked lists.
|
|
||||||
func (f *FakeAsks) Fates() (acked, termed, naked []string) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
return append([]string(nil), f.Acked...), append([]string(nil), f.Termed...), append([]string(nil), f.Naked...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next is AskSource's.
|
|
||||||
func (f *FakeAsks) Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error) {
|
|
||||||
var deadline <-chan time.Time
|
|
||||||
if wait > 0 {
|
|
||||||
t := time.NewTimer(wait)
|
|
||||||
defer t.Stop()
|
|
||||||
deadline = t.C
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
f.mu.Lock()
|
|
||||||
if q := f.waiting[kind]; len(q) > 0 {
|
|
||||||
data := q[0]
|
|
||||||
f.waiting[kind] = q[1:]
|
|
||||||
f.mu.Unlock()
|
|
||||||
return f.pulled(data), nil
|
|
||||||
}
|
|
||||||
f.mu.Unlock()
|
|
||||||
if wait <= 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
case <-deadline:
|
|
||||||
return nil, nil
|
|
||||||
case <-f.added:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FakeAsks) pulled(data []byte) *Pulled {
|
|
||||||
id := string(data)
|
|
||||||
if a, err := delivery.DecodeStepAsk(data); err == nil {
|
|
||||||
id = a.ID
|
|
||||||
}
|
|
||||||
fate := func(list *[]string) func() error {
|
|
||||||
return func() error {
|
|
||||||
f.mu.Lock()
|
|
||||||
*list = append(*list, id)
|
|
||||||
f.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delivered := f.Delivered
|
|
||||||
if delivered == 0 {
|
|
||||||
delivered = 1
|
|
||||||
}
|
|
||||||
return &Pulled{Data: data, Delivered: delivered, Ack: fate(&f.Acked), Nak: fate(&f.Naked), Term: fate(&f.Termed),
|
|
||||||
InProgress: func() error { f.mu.Lock(); f.InProgress++; f.mu.Unlock(); return nil }}
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
"github.com/nats-io/nats.go/jetstream"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
|
||||||
)
|
|
||||||
|
|
||||||
func askBus(t *testing.T, ackWait time.Duration) jetstream.JetStream {
|
|
||||||
t.Helper()
|
|
||||||
nc, err := nats.Connect(testbus.URL(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(nc.Close)
|
|
||||||
js, err := jetstream.New(nc)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := DeclareAskWorkers(context.Background(), js, delivery.AskGo, ackWait); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return js
|
|
||||||
}
|
|
||||||
|
|
||||||
func publishAsk(t *testing.T, js jetstream.JetStream, a delivery.StepAsk) {
|
|
||||||
t.Helper()
|
|
||||||
raw, err := delivery.EncodeStepAsk(a)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
subject, _ := delivery.AskSubject(a.Language, a.Kind)
|
|
||||||
if _, err := js.Publish(context.Background(), subject, raw); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1887/A2a, 1887/AC-order, 1887/AC-pull on the real server: a check ask published before a delivery ask runs after it;
|
|
||||||
// with capacity 2 and three asks two run, the third waits in the stream and starts when one ends.
|
|
||||||
func TestPullOrderAndCapacityOnTheBus(t *testing.T) {
|
|
||||||
js := askBus(t, 30*time.Second)
|
|
||||||
check, first, second := testAsk(delivery.AskCheck), testAsk(delivery.AskDelivery), testAsk(delivery.AskDelivery)
|
|
||||||
publishAsk(t, js, check)
|
|
||||||
publishAsk(t, js, first)
|
|
||||||
publishAsk(t, js, second)
|
|
||||||
release := make(chan struct{})
|
|
||||||
var mu sync.Mutex
|
|
||||||
var order []string
|
|
||||||
st := newSteps()
|
|
||||||
p := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "shanks", Put: st.put,
|
|
||||||
Capacity: func() int { return 2 }, PullWait: 200 * time.Millisecond,
|
|
||||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
|
||||||
mu.Lock()
|
|
||||||
order = append(order, a.ID)
|
|
||||||
mu.Unlock()
|
|
||||||
<-release
|
|
||||||
return nil, nil
|
|
||||||
}}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go p.Pull(ctx)
|
|
||||||
waitFor(t, "two in hand", func() bool { return p.InHand() == 2 })
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
mu.Lock()
|
|
||||||
if len(order) != 2 || order[0] != first.ID || order[1] != second.ID {
|
|
||||||
t.Fatalf("ran %v, want the two delivery asks %s %s first", order, first.ID, second.ID)
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
release <- struct{}{}
|
|
||||||
waitFor(t, "the check taken", func() bool { mu.Lock(); defer mu.Unlock(); return len(order) == 3 })
|
|
||||||
close(release)
|
|
||||||
waitFor(t, "every step ended", func() bool {
|
|
||||||
for _, a := range []delivery.StepAsk{check, first, second} {
|
|
||||||
key, _ := delivery.AskStepKey("shanks", a.ID)
|
|
||||||
if s, ok := st.get(key); !ok || s.State != delivery.StepPassed {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
info, err := js.Stream(ctx, "SEAT_NODE_BUILD_GO")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
waitFor(t, "the stream empty (every ask acknowledged)", func() bool {
|
|
||||||
si, err := info.Info(ctx)
|
|
||||||
return err == nil && si.State.Msgs == 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1887/A2c, ADR 0525 §6: a killed puller's ask is redelivered by the stream's acknowledgement wait to the other node,
|
|
||||||
// whose step key names the reference.
|
|
||||||
func TestPullRedeliversAKilledPullersAsk(t *testing.T) {
|
|
||||||
js := askBus(t, 2*time.Second)
|
|
||||||
a := testAsk(delivery.AskDelivery)
|
|
||||||
publishAsk(t, js, a)
|
|
||||||
st := newSteps()
|
|
||||||
taken := make(chan struct{})
|
|
||||||
dying := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "ace", Put: st.put,
|
|
||||||
Capacity: func() int { return 1 }, PullWait: 200 * time.Millisecond, Heartbeat: time.Hour,
|
|
||||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
|
||||||
close(taken)
|
|
||||||
<-ctx.Done()
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}}
|
|
||||||
kill, killed := context.WithCancel(context.Background())
|
|
||||||
go dying.Pull(kill)
|
|
||||||
<-taken
|
|
||||||
killed()
|
|
||||||
other := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "shanks", Put: st.put,
|
|
||||||
Capacity: func() int { return 1 }, PullWait: 200 * time.Millisecond,
|
|
||||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
|
||||||
return delivery.BuildAnswer{Artifacts: []delivery.AskBuilt{{Artifact: "bundle", Digest: "sha256:00", Reference: "artifact-store://x"}}}, nil
|
|
||||||
}}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go other.Pull(ctx)
|
|
||||||
key, _ := delivery.AskStepKey("shanks", a.ID)
|
|
||||||
deadline := time.Now().Add(10 * time.Second)
|
|
||||||
for {
|
|
||||||
if s, ok := st.get(key); ok && s.State == delivery.StepPassed {
|
|
||||||
if want := `artifact-store://x`; !contains(string(s.Result), want) {
|
|
||||||
t.Fatalf("the step's result %s names no %s", s.Result, want)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("the killed puller's ask was not taken by shanks within 10 s")
|
|
||||||
}
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SeatAsks is a build seat's workers as a tools bundle reaches them: through its runtime's seat pull and settle
|
|
||||||
// (stdio.SeatPull, SeatSettle; issue 1887 A11), one message per pull, only from the workers the module's membership
|
|
||||||
// lists.
|
|
||||||
type SeatAsks struct {
|
|
||||||
Language string
|
|
||||||
// Pull and Settle are stdio.SeatPull and stdio.SeatSettle when nil; a test gives its own.
|
|
||||||
Pull func(worker string, wait time.Duration) (*stdio.Pulled, error)
|
|
||||||
Settle func(id, fate string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next is AskSource's.
|
|
||||||
func (s SeatAsks) Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error) {
|
|
||||||
pull, settle := s.Pull, s.Settle
|
|
||||||
if pull == nil {
|
|
||||||
pull = stdio.SeatPull
|
|
||||||
}
|
|
||||||
if settle == nil {
|
|
||||||
settle = stdio.SeatSettle
|
|
||||||
}
|
|
||||||
worker, err := delivery.AskWorker(s.Language, kind)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p, err := pull(worker, wait)
|
|
||||||
if err != nil || p == nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
fate := func(f string) func() error { return func() error { return settle(p.ID, f) } }
|
|
||||||
return &Pulled{Data: p.Body, Delivered: p.Delivered, Ack: fate(stdio.SettleAck), InProgress: fate(stdio.SettleProgress),
|
|
||||||
Nak: fate(stdio.SettleNak), Term: fate(stdio.SettleTerm)}, nil
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1887/A11: a bundle's source pulls the kind's worker by its seat's name and settles each ask by the runtime's id.
|
|
||||||
func TestSeatAsksPullsTheKindsWorkerAndSettlesByID(t *testing.T) {
|
|
||||||
var pulled []string
|
|
||||||
var settled []string
|
|
||||||
src := SeatAsks{Language: delivery.AskGo,
|
|
||||||
Pull: func(worker string, wait time.Duration) (*stdio.Pulled, error) {
|
|
||||||
pulled = append(pulled, worker)
|
|
||||||
if worker != "SEAT_NODE_BUILD_GO_CHECK_worker" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &stdio.Pulled{ID: "p9", Worker: worker, Body: []byte(`{"v":1}`), Delivered: 2}, nil
|
|
||||||
},
|
|
||||||
Settle: func(id, fate string) error { settled = append(settled, id+" "+fate); return nil }}
|
|
||||||
if p, err := src.Next(context.Background(), delivery.AskDelivery, 0); err != nil || p != nil {
|
|
||||||
t.Fatalf("delivery = %+v, %v", p, err)
|
|
||||||
}
|
|
||||||
p, err := src.Next(context.Background(), delivery.AskCheck, time.Second)
|
|
||||||
if err != nil || p == nil || string(p.Data) != `{"v":1}` || p.Delivered != 2 {
|
|
||||||
t.Fatalf("check = %+v, %v", p, err)
|
|
||||||
}
|
|
||||||
_ = p.InProgress()
|
|
||||||
_ = p.Ack()
|
|
||||||
if len(pulled) != 2 || pulled[0] != "SEAT_NODE_BUILD_GO_DELIVERY_worker" {
|
|
||||||
t.Errorf("pulled %v", pulled)
|
|
||||||
}
|
|
||||||
if len(settled) != 2 || settled[0] != "p9 progress" || settled[1] != "p9 ack" {
|
|
||||||
t.Errorf("settled %v", settled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// steps records a steps state's puts: the last value of each key.
|
|
||||||
type steps struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
keys map[string]delivery.Step
|
|
||||||
}
|
|
||||||
|
|
||||||
func newSteps() *steps { return &steps{keys: map[string]delivery.Step{}} }
|
|
||||||
|
|
||||||
func (s *steps) put(key string, value any) (uint64, error) {
|
|
||||||
raw, _ := value.(json.RawMessage)
|
|
||||||
st, err := delivery.DecodeStep(raw)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.keys[key] = st
|
|
||||||
s.mu.Unlock()
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *steps) get(key string) (delivery.Step, bool) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
st, ok := s.keys[key]
|
|
||||||
return st, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func testAsk(kind string) delivery.StepAsk {
|
|
||||||
id := NewID()
|
|
||||||
a := delivery.StepAsk{ID: id, Kind: kind, Language: delivery.AskGo, Asker: "mesh-delivery-builder", At: time.Now()}
|
|
||||||
fetch := map[string]any{"repository": "novox/x", "commit": "0123456789abcdef0123456789abcdef01234567"}
|
|
||||||
if kind == delivery.AskDelivery {
|
|
||||||
b := buildJobFixture
|
|
||||||
b.Fetch = fetch
|
|
||||||
a.Build = &b
|
|
||||||
} else {
|
|
||||||
a.Check = &delivery.CheckJob{Fetch: fetch, Levels: []string{delivery.LevelUnit}}
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
var buildJobFixture = delivery.BuildJob{Module: "m", Path: ".", Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}}
|
|
||||||
|
|
||||||
func waitFor(t *testing.T, what string, cond func() bool) {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for !cond() {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("waited 2 s for %s", what)
|
|
||||||
}
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1887/A2a: given a check ask published before a delivery ask and one slot, the delivery ask runs first.
|
|
||||||
func TestPullTakesDeliveryBeforeCheck(t *testing.T) {
|
|
||||||
src, st := NewFakeAsks(), newSteps()
|
|
||||||
check, recheck, deliver := testAsk(delivery.AskCheck), testAsk(delivery.AskRecheck), testAsk(delivery.AskDelivery)
|
|
||||||
for _, a := range []delivery.StepAsk{recheck, check, deliver} {
|
|
||||||
if err := src.PublishAsk(a); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var mu sync.Mutex
|
|
||||||
var order []string
|
|
||||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
|
|
||||||
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
|
||||||
mu.Lock()
|
|
||||||
order = append(order, a.Kind)
|
|
||||||
mu.Unlock()
|
|
||||||
return map[string]string{"ran": a.ID}, nil
|
|
||||||
}}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go p.Pull(ctx)
|
|
||||||
waitFor(t, "three asks acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 })
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if want := []string{delivery.AskDelivery, delivery.AskCheck, delivery.AskRecheck}; len(order) != 3 || order[0] != want[0] || order[1] != want[1] || order[2] != want[2] {
|
|
||||||
t.Fatalf("order = %v, want %v", order, want)
|
|
||||||
}
|
|
||||||
key, _ := delivery.AskStepKey("shanks", deliver.ID)
|
|
||||||
s, ok := st.get(key)
|
|
||||||
if !ok || s.State != delivery.StepPassed || string(s.Result) != `{"ran":"`+deliver.ID+`"}` {
|
|
||||||
t.Fatalf("the step %s = %+v (%v)", key, s, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1887/A2a, 1887/AC-pull: given capacity 2 and three asks waiting, two run and the third waits, and it starts when
|
|
||||||
// one ends, with no timer.
|
|
||||||
func TestPullHoldsAtMostItsCapacity(t *testing.T) {
|
|
||||||
src, st := NewFakeAsks(), newSteps()
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
|
||||||
}
|
|
||||||
release := make(chan struct{})
|
|
||||||
var mu sync.Mutex
|
|
||||||
started := 0
|
|
||||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 2 },
|
|
||||||
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
|
||||||
mu.Lock()
|
|
||||||
started++
|
|
||||||
mu.Unlock()
|
|
||||||
<-release
|
|
||||||
return nil, nil
|
|
||||||
}}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go p.Pull(ctx)
|
|
||||||
waitFor(t, "two in hand", func() bool { return p.InHand() == 2 })
|
|
||||||
time.Sleep(30 * time.Millisecond) // three pull waits: the third must still wait
|
|
||||||
if p.InHand() != 2 || src.Waiting(delivery.AskDelivery) != 1 {
|
|
||||||
t.Fatalf("in hand %d, waiting %d; want 2 and 1", p.InHand(), src.Waiting(delivery.AskDelivery))
|
|
||||||
}
|
|
||||||
release <- struct{}{}
|
|
||||||
waitFor(t, "the third taken", func() bool { return src.Waiting(delivery.AskDelivery) == 0 })
|
|
||||||
// Taken from the source comes a moment before its run starts: wait for the start, not only the take.
|
|
||||||
waitFor(t, "the third started", func() bool { mu.Lock(); defer mu.Unlock(); return started == 3 })
|
|
||||||
close(release)
|
|
||||||
waitFor(t, "all acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// An ask that is not one, or not this seat's, is dropped and runs nowhere.
|
|
||||||
func TestPullDropsWhatIsNoAsk(t *testing.T) {
|
|
||||||
src, st := NewFakeAsks(), newSteps()
|
|
||||||
src.Publish(delivery.AskDelivery, []byte(`{"v":1}`))
|
|
||||||
other := testAsk(delivery.AskDelivery)
|
|
||||||
other.Language = delivery.AskNode
|
|
||||||
_ = src.PublishAsk(other)
|
|
||||||
ran := false
|
|
||||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
|
|
||||||
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go p.Pull(ctx)
|
|
||||||
waitFor(t, "two termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 2 })
|
|
||||||
if ran {
|
|
||||||
t.Fatal("a wrong ask ran")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// load reads the recorded steps state's raw values: the pause key is not a step, so it is kept apart.
|
|
||||||
type kv struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
vals map[string][]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *kv) put(key string, value any) (uint64, error) {
|
|
||||||
raw, _ := value.(json.RawMessage)
|
|
||||||
k.mu.Lock()
|
|
||||||
defer k.mu.Unlock()
|
|
||||||
if k.vals == nil {
|
|
||||||
k.vals = map[string][]byte{}
|
|
||||||
}
|
|
||||||
k.vals[key] = append([]byte(nil), raw...)
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *kv) load(key string) ([]byte, error) {
|
|
||||||
k.mu.Lock()
|
|
||||||
defer k.mu.Unlock()
|
|
||||||
return k.vals[key], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1887/A2b, 1887/AC-zero: at capacity 0 the puller takes nothing; a capacity raised takes effect at the next pull.
|
|
||||||
func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
|
|
||||||
src, st := NewFakeAsks(), &kv{}
|
|
||||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
|
||||||
var mu sync.Mutex
|
|
||||||
capacity := 0
|
|
||||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond,
|
|
||||||
Capacity: func() int { mu.Lock(); defer mu.Unlock(); return capacity },
|
|
||||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go p.Pull(ctx)
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
if src.Waiting(delivery.AskDelivery) != 1 {
|
|
||||||
t.Fatal("a node at capacity 0 took an ask")
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
capacity = 1
|
|
||||||
mu.Unlock()
|
|
||||||
p.Wake()
|
|
||||||
waitFor(t, "the ask taken at capacity 1", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1887/A2b: a paused puller takes nothing, its pause is kept on the pause key, and a puller started again on that state
|
|
||||||
// stays paused until resumed.
|
|
||||||
func TestPullPauseOutlivesARestart(t *testing.T) {
|
|
||||||
src, st := NewFakeAsks(), &kv{}
|
|
||||||
run := func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }
|
|
||||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Load: st.load, PullWait: 10 * time.Millisecond,
|
|
||||||
Capacity: func() int { return 2 }, Run: run}
|
|
||||||
if err := p.Pause("the operator"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
key, _ := delivery.PauseKey("shanks")
|
|
||||||
raw, _ := st.load(key)
|
|
||||||
if v, err := delivery.DecodePause(raw); err != nil || !v.Paused || v.By != "the operator" {
|
|
||||||
t.Fatalf("the pause key holds %s (%v)", raw, err)
|
|
||||||
}
|
|
||||||
// The restart: a new puller on the same state.
|
|
||||||
again := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Load: st.load, PullWait: 10 * time.Millisecond,
|
|
||||||
Capacity: func() int { return 2 }, Run: run}
|
|
||||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go again.Pull(ctx)
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
if !again.Paused() || src.Waiting(delivery.AskDelivery) != 1 {
|
|
||||||
t.Fatalf("restarted: paused %v, waiting %d; want paused and 1", again.Paused(), src.Waiting(delivery.AskDelivery))
|
|
||||||
}
|
|
||||||
if err := again.Resume("the operator"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
waitFor(t, "the ask taken after the resume", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1887/A2c: a long step tells the bus it is still being worked; a holder that stops mid-step neither ends nor
|
|
||||||
// acknowledges its ask, so the stream gives it to another node.
|
|
||||||
func TestPullHeartbeatAndStop(t *testing.T) {
|
|
||||||
src, st := NewFakeAsks(), newSteps()
|
|
||||||
a := testAsk(delivery.AskDelivery)
|
|
||||||
_ = src.PublishAsk(a)
|
|
||||||
running := make(chan struct{})
|
|
||||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
|
|
||||||
PullWait: 10 * time.Millisecond, Heartbeat: 5 * time.Millisecond,
|
|
||||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
|
||||||
close(running)
|
|
||||||
<-ctx.Done()
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
stopped := make(chan struct{})
|
|
||||||
go func() { p.Pull(ctx); close(stopped) }()
|
|
||||||
<-running
|
|
||||||
waitFor(t, "two heartbeats", func() bool { src.mu.Lock(); defer src.mu.Unlock(); return src.InProgress >= 2 })
|
|
||||||
cancel()
|
|
||||||
<-stopped
|
|
||||||
waitFor(t, "the step's goroutine gone", func() bool { return p.InHand() == 0 })
|
|
||||||
acked, termed, naked := src.Fates()
|
|
||||||
if len(acked)+len(termed)+len(naked) != 0 {
|
|
||||||
t.Fatalf("a stopped holder gave its ask a fate: acked %v termed %v naked %v", acked, termed, naked)
|
|
||||||
}
|
|
||||||
key, _ := delivery.AskStepKey("shanks", a.ID)
|
|
||||||
if s, ok := st.get(key); !ok || s.State != delivery.StepRunning {
|
|
||||||
t.Fatalf("the step %s = %+v, want left running", key, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1907: an ask taken past AskRuns (each run took its node down) is run no more: its step ends failed saying why, so the
|
|
||||||
// asker's key fails, and it is ended on the stream, not offered to another node.
|
|
||||||
func TestAPoisonedAskIsSaidAndRunNoMore(t *testing.T) {
|
|
||||||
src, st := NewFakeAsks(), newSteps()
|
|
||||||
src.Delivered = delivery.AskRuns + 1
|
|
||||||
a := testAsk(delivery.AskDelivery)
|
|
||||||
_ = src.PublishAsk(a)
|
|
||||||
ran := false
|
|
||||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
|
|
||||||
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go p.Pull(ctx)
|
|
||||||
waitFor(t, "the ask termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 1 })
|
|
||||||
key, _ := delivery.AskStepKey("shanks", a.ID)
|
|
||||||
s, ok := st.get(key)
|
|
||||||
if ran || !ok || s.State != delivery.StepFailed || !strings.Contains(s.Reason, "taken 3 times") || !strings.Contains(s.Reason, "poison") {
|
|
||||||
t.Fatalf("ran %v, step %+v", ran, s)
|
|
||||||
}
|
|
||||||
if acked, _, naked := src.Fates(); len(acked)+len(naked) != 0 {
|
|
||||||
t.Errorf("acked %v naked %v, want it termed alone", acked, naked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// Refusal is a step that is not run on purpose, as against one that failed: the toolchain installed is not the
|
|
||||||
// recipe's, the integration level's postgres major is not the one it is built for (novox/hq ADR 0469 §1, ADR 0470
|
|
||||||
// §4; issue 1473). A step's run returns one and the runner ends the step refused with its text.
|
|
||||||
type Refusal struct {
|
|
||||||
// Text is the reason, in words the operator reads.
|
|
||||||
Text string
|
|
||||||
// Want is the version the recipe asks for and Have the one found; either may be empty.
|
|
||||||
Want, Have string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Refusal) Error() string { return r.Text }
|
|
||||||
|
|
||||||
// Refuse is a Refusal with its text formatted.
|
|
||||||
func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} }
|
|
||||||
@@ -2,9 +2,12 @@ package buildstep
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -27,6 +30,10 @@ type FetchRequest struct {
|
|||||||
Base string `json:"base,omitempty"`
|
Base string `json:"base,omitempty"`
|
||||||
Dir string `json:"dir,omitempty"`
|
Dir string `json:"dir,omitempty"`
|
||||||
Paths []string `json:"paths,omitempty"`
|
Paths []string `json:"paths,omitempty"`
|
||||||
|
// Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages,
|
||||||
|
// novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version
|
||||||
|
// the repository names, checksum-verified, and names them in its log.
|
||||||
|
Packages delivery.PackageVersions `json:"packages,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
|
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
|
||||||
@@ -35,6 +42,8 @@ func FetchRequestProperties() map[string]any {
|
|||||||
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
|
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
|
||||||
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||||
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
|
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
|
||||||
|
"packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"},
|
||||||
|
"description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +58,12 @@ func (r FetchRequest) Args() map[string]any {
|
|||||||
if len(r.Paths) > 0 {
|
if len(r.Paths) > 0 {
|
||||||
args["paths"] = append([]string(nil), r.Paths...)
|
args["paths"] = append([]string(nil), r.Paths...)
|
||||||
}
|
}
|
||||||
|
if len(r.Packages) > 0 {
|
||||||
|
args["packages"] = map[string]any{}
|
||||||
|
for p, v := range r.Packages {
|
||||||
|
args["packages"].(map[string]any)[p] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +93,42 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
|
|||||||
default:
|
default:
|
||||||
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
|
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
|
||||||
}
|
}
|
||||||
|
packages, err := PackagesArg(args["packages"])
|
||||||
|
if err != nil {
|
||||||
|
return FetchRequest{}, err
|
||||||
|
}
|
||||||
|
r.Packages = packages
|
||||||
return r, r.Check()
|
return r, r.Check()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives;
|
||||||
|
// nil for none.
|
||||||
|
func PackagesArg(v any) (delivery.PackageVersions, error) {
|
||||||
|
out := delivery.PackageVersions{}
|
||||||
|
switch v := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil, nil
|
||||||
|
case delivery.PackageVersions:
|
||||||
|
maps.Copy(out, v)
|
||||||
|
case map[string]string:
|
||||||
|
maps.Copy(out, v)
|
||||||
|
case map[string]any:
|
||||||
|
for p, version := range v {
|
||||||
|
s, ok := version.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
|
||||||
|
}
|
||||||
|
out[p] = s
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return out, out.Check()
|
||||||
|
}
|
||||||
|
|
||||||
// Check is what ParseFetchRequest refuses.
|
// Check is what ParseFetchRequest refuses.
|
||||||
func (r FetchRequest) Check() error {
|
func (r FetchRequest) Check() error {
|
||||||
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
|
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ import (
|
|||||||
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
|
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
|
||||||
// clause 1). The request survives the trip through a tool call's arguments.
|
// clause 1). The request survives the trip through a tool call's arguments.
|
||||||
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
||||||
|
// And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4).
|
||||||
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
|
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
|
||||||
Dir: ".", Paths: []string{"package.json", "src"}}
|
Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}}
|
||||||
args := req.Args()
|
args := req.Args()
|
||||||
// A tool call's arguments arrive as JSON: lists of any.
|
// A tool call's arguments arrive as JSON: lists of any, objects of any.
|
||||||
args["paths"] = []any{"package.json", "src"}
|
args["paths"] = []any{"package.json", "src"}
|
||||||
|
if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) {
|
||||||
|
t.Fatalf("the packages are not in the call's arguments: %v", args["packages"])
|
||||||
|
}
|
||||||
got, err := ParseFetchRequest(args)
|
got, err := ParseFetchRequest(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -42,6 +46,9 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
|||||||
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
|
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
|
||||||
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
|
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
|
||||||
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
|
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
|
||||||
|
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "main"}}, "neither a full commit nor a Go semantic version"},
|
||||||
|
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
|
||||||
|
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
|
||||||
} {
|
} {
|
||||||
args := map[string]any{}
|
args := map[string]any{}
|
||||||
for k, v := range ok {
|
for k, v := range ok {
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SeatTools are a build seat's verbs on one node, as its holder serves them (novox/hq ADR 0525 §10; mesh-sdk catalogue
|
|
||||||
// buildSeatVerbs): `<seat>.current` (what the node runs, its capacity, whether it is paused), `<seat>.pause` and
|
|
||||||
// `<seat>.resume`, the pause kept on the node's pause key by the puller. what names the asks in the descriptions ("Go").
|
|
||||||
func SeatTools(seat, what string, p *Puller) []stdio.Tool {
|
|
||||||
verb := func(name, description string, run func() (any, error)) stdio.Tool {
|
|
||||||
return stdio.Tool{Name: seat + "." + name, Description: description,
|
|
||||||
Input: map[string]any{"type": "object", "properties": map[string]any{}},
|
|
||||||
Run: func(map[string]any) (any, error) { return run() }}
|
|
||||||
}
|
|
||||||
current := func() (any, error) {
|
|
||||||
capacity := 0
|
|
||||||
if p.Capacity != nil {
|
|
||||||
capacity = p.Capacity()
|
|
||||||
}
|
|
||||||
return map[string]any{"node": p.Node, "capacity": capacity, "running": p.Running(), "paused": p.Paused()}, nil
|
|
||||||
}
|
|
||||||
return []stdio.Tool{
|
|
||||||
verb("current", "The "+what+" asks this node runs now (each one's id, kind and when it started), its build capacity, "+
|
|
||||||
"and whether it is paused.", current),
|
|
||||||
verb("pause", "Take no new "+what+" ask on this node until resumed; an ask running here ends in its place. Kept "+
|
|
||||||
"across a restart (the node's pause key).", func() (any, error) {
|
|
||||||
if err := p.Pause("the seat's pause verb"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return current()
|
|
||||||
}),
|
|
||||||
verb("resume", "Take "+what+" asks again on this node.", func() (any, error) {
|
|
||||||
if err := p.Resume("the seat's resume verb"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return current()
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1887/A9a, ADR 0525 §10: a build seat's verbs are <seat>.current, .pause and .resume; current names the ask running,
|
|
||||||
// pause stops the puller taking asks, resume lets it take them again.
|
|
||||||
func TestSeatToolsCurrentPauseResume(t *testing.T) {
|
|
||||||
src, st := NewFakeAsks(), newSteps()
|
|
||||||
release := make(chan struct{})
|
|
||||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
|
|
||||||
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { <-release; return nil, nil }}
|
|
||||||
tools := map[string]func(map[string]any) (any, error){}
|
|
||||||
for _, tool := range SeatTools("node-build-go", "Go", p) {
|
|
||||||
tools[tool.Name] = tool.Run
|
|
||||||
}
|
|
||||||
for _, name := range []string{"node-build-go.current", "node-build-go.pause", "node-build-go.resume"} {
|
|
||||||
if tools[name] == nil {
|
|
||||||
t.Fatalf("no %s", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a := testAsk(delivery.AskDelivery)
|
|
||||||
_ = src.PublishAsk(a)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
go p.Pull(ctx)
|
|
||||||
waitFor(t, "the ask running", func() bool { return p.InHand() == 1 })
|
|
||||||
out, _ := tools["node-build-go.current"](nil)
|
|
||||||
cur := out.(map[string]any)
|
|
||||||
running := cur["running"].([]RunningAsk)
|
|
||||||
if len(running) != 1 || running[0].ID != a.ID || running[0].Module != "m" || cur["capacity"] != 1 || cur["paused"] != false {
|
|
||||||
t.Fatalf("current = %+v", cur)
|
|
||||||
}
|
|
||||||
if out, err := tools["node-build-go.pause"](nil); err != nil || out.(map[string]any)["paused"] != true {
|
|
||||||
t.Fatalf("pause = %v, %v", out, err)
|
|
||||||
}
|
|
||||||
close(release)
|
|
||||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
if src.Waiting(delivery.AskDelivery) != 1 {
|
|
||||||
t.Fatal("a paused node took an ask")
|
|
||||||
}
|
|
||||||
if _, err := tools["node-build-go.resume"](nil); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
waitFor(t, "the ask taken after the resume", func() bool { return src.Waiting(delivery.AskDelivery) == 0 })
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package buildstep
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestartReason is why a step a previous process left running is ended.
|
||||||
|
const RestartReason = "the build module restarted while it ran"
|
||||||
|
|
||||||
|
// Boot is when this process began. A step with no owner (written by an older build) started at or after it is taken
|
||||||
|
// for this process's, which may still run it, so EndOne refuses it.
|
||||||
|
var Boot = time.Now()
|
||||||
|
|
||||||
|
// Owner is this process as a step's owner (delivery.Step.Owner): `<boot id>.<pid>.<start>`, the node's boot id
|
||||||
|
// (/proc/sys/kernel/random/boot_id), the pid and Boot in milliseconds, base 36. The start keeps two processes apart
|
||||||
|
// that a reboot or a pid namespace gave the same pid. A running step whose owner is another is a previous process's.
|
||||||
|
var Owner = ownerOf(bootID, os.Getpid(), Boot)
|
||||||
|
|
||||||
|
// bootID is the node's boot id as Owner holds it; noBootID when it could not be read, which EndStaleAtStart says once.
|
||||||
|
var bootID = readBootID()
|
||||||
|
|
||||||
|
const noBootID = "no-boot-id"
|
||||||
|
|
||||||
|
func ownerOf(bootID string, pid int, start time.Time) string {
|
||||||
|
return bootID + "." + strconv.Itoa(pid) + "." + strconv.FormatInt(start.UnixMilli(), 36)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBootID is the node's boot id, or noBootID where it cannot be read (the pid and the start then tell the processes
|
||||||
|
// apart).
|
||||||
|
func readBootID() string {
|
||||||
|
b, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
|
||||||
|
if id := strings.TrimSpace(string(b)); err == nil && id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return noBootID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan reads and writes a steps state with its revisions: its keys, a key's stored value and revision (nil and 0 for
|
||||||
|
// none), a write that lands only while the key is still at the revision read (KeptState.Update), and a delete of a key
|
||||||
|
// still at the revision read; both fail with stdio.ErrChanged when another writer was first.
|
||||||
|
type Scan struct {
|
||||||
|
Keys func() ([]string, error)
|
||||||
|
Get func(key string) (json.RawMessage, uint64, error)
|
||||||
|
Update func(key string, value any, revision uint64) (uint64, error)
|
||||||
|
Delete func(key string, revision uint64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanOf is the Scan of a state the process keeps.
|
||||||
|
//
|
||||||
|
// Its Delete reads the key again and deletes it only while it is still at the revision read: the runtime's
|
||||||
|
// mesh/state.delete takes no revision yet, so a write landing between that read and the delete (a few milliseconds) is
|
||||||
|
// deleted with it. For a step key that is a redelivered ask begun on this node in that moment: its running key leaves
|
||||||
|
// the steps state until its run writes its end, which its asker still reads (a watch takes puts only).
|
||||||
|
func ScanOf(s stdio.KeptState) Scan {
|
||||||
|
get := func(key string) (json.RawMessage, uint64, error) {
|
||||||
|
e, err := s.Get(key)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return e.Value, e.Revision, nil
|
||||||
|
}
|
||||||
|
return Scan{Keys: s.Keys, Update: s.Update, Get: get, Delete: func(key string, revision uint64) error {
|
||||||
|
_, rev, err := get(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rev != revision {
|
||||||
|
return stdio.ErrChanged
|
||||||
|
}
|
||||||
|
return s.Delete(key)
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAsk says a step is a pulled ask's: its key is <node>.<ask id> and its tool is "<kind> ask" (a hand-started step
|
||||||
|
// has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was
|
||||||
|
// running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key.
|
||||||
|
// A key a gone process owns is deleted, never ended (EndStale); one with no owner is ended only once the asker's bound
|
||||||
|
// has passed (delivery.StepBound), when it is nobody's.
|
||||||
|
func isAsk(key string, s delivery.Step) bool {
|
||||||
|
_, _, err := delivery.ParseAskStepKey(key)
|
||||||
|
return err == nil && strings.HasSuffix(s.Tool, " ask")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AskWhat says what an ask is, in one line: its kind, and its repository, head and levels, or its module and
|
||||||
|
// fingerprint.
|
||||||
|
func AskWhat(a delivery.StepAsk) string {
|
||||||
|
short := func(s string) string {
|
||||||
|
if len(s) > 12 {
|
||||||
|
return s[:12]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
str := func(m map[string]any, k string) string { v, _ := m[k].(string); return v }
|
||||||
|
parts := []string{a.Kind + " ask"}
|
||||||
|
switch {
|
||||||
|
case a.Check != nil:
|
||||||
|
parts = append(parts, str(a.Check.Fetch, "repository")+"@"+short(str(a.Check.Fetch, "commit")), "levels "+strings.Join(a.Check.Levels, ","))
|
||||||
|
if a.Check.Run != "" {
|
||||||
|
parts = append(parts, "run "+a.Check.Run)
|
||||||
|
}
|
||||||
|
case a.Build != nil:
|
||||||
|
parts = append(parts, "module "+a.Build.Module)
|
||||||
|
if r := str(a.Build.Fetch, "repository"); r != "" {
|
||||||
|
parts = append(parts, r+"@"+short(str(a.Build.Fetch, "commit")))
|
||||||
|
}
|
||||||
|
if a.Build.FP != "" {
|
||||||
|
parts = append(parts, "fp "+short(a.Build.FP))
|
||||||
|
}
|
||||||
|
case a.Compose != nil:
|
||||||
|
parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit")))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// endRunning writes step ended failed with why, only while its key is still at revision.
|
||||||
|
func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why string) error {
|
||||||
|
step.State, step.Reason, step.Ended, step.Result = delivery.StepFailed, why, now().UTC(), nil
|
||||||
|
raw, err := delivery.EncodeStep(step)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = scan.Update(key, json.RawMessage(raw), revision)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndStale ends this node's running steps that no process of this module runs any more, and answers their ids. Only keys
|
||||||
|
// of node are read; a value that is no step is left alone.
|
||||||
|
// - A hand-started step with an owner (delivery.Step.Owner) that is not this process (Owner) is ended failed with
|
||||||
|
// RestartReason at once, whatever its age: its process is gone and nothing redelivers it, so nothing else would
|
||||||
|
// write its end (issue 1890, note 21).
|
||||||
|
// - A pulled ask's step with an owner that is not this process is deleted, not ended (novox/hq ADR 0525 §6): the
|
||||||
|
// stream redelivers an ask whose node stopped (the puller neither ends nor acknowledges one at its stop), and its
|
||||||
|
// asker takes the first end it reads, so an end written here would fail a check the redelivery would still run. Its
|
||||||
|
// asker's watch takes puts only and keeps waiting for the redelivery's key, its bound the backstop; the stale running
|
||||||
|
// key leaves the steps state, and with it the delivery view's and the checker's count of what runs (note 21: it
|
||||||
|
// held one of a build node's two slots).
|
||||||
|
// - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it.
|
||||||
|
// - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before
|
||||||
|
// `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its
|
||||||
|
// start is older than delivery.StepBound, and then ended "past the step bound: its asker stopped waiting at <time>".
|
||||||
|
//
|
||||||
|
// Each end and delete is logged, naming the step, its owner and why. A key written by another writer since it was read
|
||||||
|
// (a redelivered ask's start, a real end) is skipped.
|
||||||
|
func EndStale(scan Scan, node string, before time.Time, logf func(string, ...any)) ([]string, error) {
|
||||||
|
keys, err := scan.Keys()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("buildstep: listing the steps of %s: %w", node, err)
|
||||||
|
}
|
||||||
|
var ended []string
|
||||||
|
for _, k := range keys {
|
||||||
|
n, _, perr := delivery.ParseStepKey(k)
|
||||||
|
if perr != nil || n != node {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, rev, err := scan.Get(k)
|
||||||
|
if err != nil {
|
||||||
|
return ended, fmt.Errorf("buildstep: reading the step %s: %w", k, err)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
step, err := delivery.DecodeStep(raw)
|
||||||
|
if err != nil || step.State != delivery.StepRunning {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
why, said := staleWhy(k, step, before)
|
||||||
|
if why == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
done := "ended"
|
||||||
|
if step.Owner != "" && isAsk(k, step) {
|
||||||
|
done = "deleted, left to the stream's redelivery"
|
||||||
|
if scan.Delete == nil {
|
||||||
|
err = fmt.Errorf("the steps state takes no delete")
|
||||||
|
} else {
|
||||||
|
err = scan.Delete(k, rev)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = endRunning(scan, k, step, rev, why)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, stdio.ErrChanged) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ended, fmt.Errorf("buildstep: ending the step %s: %w", k, err)
|
||||||
|
}
|
||||||
|
if logf != nil {
|
||||||
|
logf("the step %s (%s, started %s) was left running and is %s: %s; %s", k, step.Tool,
|
||||||
|
step.Started.UTC().Format(time.RFC3339), done, why, said)
|
||||||
|
}
|
||||||
|
ended = append(ended, step.ID)
|
||||||
|
}
|
||||||
|
return ended, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// staleWhy is why a running step is ended (or, for an ask with an owner, deleted) by EndStale and what the log says of
|
||||||
|
// its owner, or "" to leave it.
|
||||||
|
func staleWhy(key string, step delivery.Step, before time.Time) (why, said string) {
|
||||||
|
switch {
|
||||||
|
case step.Owner == Owner:
|
||||||
|
return "", ""
|
||||||
|
case step.Owner != "":
|
||||||
|
return RestartReason, "its owner " + step.Owner + " is not this process " + Owner
|
||||||
|
case isAsk(key, step):
|
||||||
|
if !step.Started.Add(delivery.StepBound).Before(now()) {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
return "past the step bound: its asker stopped waiting at " + step.Started.Add(delivery.StepBound).UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
"it names no owner (an older build wrote it)"
|
||||||
|
case step.Started.Before(before):
|
||||||
|
return RestartReason, "it names no owner (an older build wrote it) and started before this process"
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndOne ends this node's running step id (or the key <node>.<id>) as failed with why. A step that is not running is
|
||||||
|
// refused, and so is one this process owns (Owner; for a step with no owner, one started since Boot: it may still run,
|
||||||
|
// whether a pulled ask or a step a tool started), one live reports as held, and one whose key changed since it was read.
|
||||||
|
func EndOne(scan Scan, node, id, why string, live func(id string) bool) (delivery.Step, error) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if n, rest, ok := strings.Cut(id, "."); ok {
|
||||||
|
if n != node {
|
||||||
|
return delivery.Step{}, fmt.Errorf("the step %q is of node %q, this module runs on %q", id, n, node)
|
||||||
|
}
|
||||||
|
id = rest
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(why) == "" {
|
||||||
|
return delivery.Step{}, fmt.Errorf("say why the step is ended (why)")
|
||||||
|
}
|
||||||
|
key, err := delivery.StepKey(node, id)
|
||||||
|
if err != nil {
|
||||||
|
return delivery.Step{}, err
|
||||||
|
}
|
||||||
|
raw, rev, err := scan.Get(key)
|
||||||
|
if err != nil {
|
||||||
|
return delivery.Step{}, err
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
|
||||||
|
}
|
||||||
|
step, err := delivery.DecodeStep(raw)
|
||||||
|
if err != nil {
|
||||||
|
return delivery.Step{}, err
|
||||||
|
}
|
||||||
|
if step.State != delivery.StepRunning {
|
||||||
|
return delivery.Step{}, fmt.Errorf("the step %s is %s, not running", key, step.State)
|
||||||
|
}
|
||||||
|
if step.Owner == Owner || (step.Owner == "" && !step.Started.Before(Boot)) || (live != nil && live(id)) {
|
||||||
|
return delivery.Step{}, fmt.Errorf("the step %s was started by this process and may run now; only a step a previous process left is ended", key)
|
||||||
|
}
|
||||||
|
if err := endRunning(scan, key, step, rev, why); err != nil {
|
||||||
|
if errors.Is(err, stdio.ErrChanged) {
|
||||||
|
return delivery.Step{}, fmt.Errorf("the step %s changed while it was being ended; read it again", key)
|
||||||
|
}
|
||||||
|
return delivery.Step{}, err
|
||||||
|
}
|
||||||
|
step.State, step.Reason = delivery.StepFailed, why
|
||||||
|
return step, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaleAttempts and StaleRetry bound EndStaleAtStart's tries: the steps state is readable once the runtime serves.
|
||||||
|
var (
|
||||||
|
StaleAttempts = 6
|
||||||
|
StaleRetry = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaleServed is what EndStaleAtStart waits for before it reads the steps state: the runtime serving (stdio.Served).
|
||||||
|
// The conformance kit's left-running case (conformance.LeftRunning) hands it a closed channel, to run a module's own
|
||||||
|
// start sweep in its test.
|
||||||
|
var StaleServed = stdio.Served
|
||||||
|
|
||||||
|
// EndStaleAtStart is what a build module runs once at its start, in a goroutine: it waits for the runtime to serve,
|
||||||
|
// then ends this node's steps a previous process left running (EndStale), trying again when the state cannot be read
|
||||||
|
// yet. A step names its owner, so `before` (the start of this process) matters only for a step with no owner, written
|
||||||
|
// by an older build: such a step started before it is a previous process's. When the node's boot id cannot be read it
|
||||||
|
// says so once: Owner then tells processes apart by their pid and start only.
|
||||||
|
func EndStaleAtStart(ctx context.Context, scan Scan, node string, before time.Time, logf func(string, ...any)) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-StaleServed():
|
||||||
|
}
|
||||||
|
if bootID == noBootID && logf != nil {
|
||||||
|
logf("the boot id of %s cannot be read (/proc/sys/kernel/random/boot_id): its steps' owner is %s, told apart by pid and start only", node, Owner)
|
||||||
|
}
|
||||||
|
for attempt := 1; ; attempt++ {
|
||||||
|
ended, err := EndStale(scan, node, before, logf)
|
||||||
|
if err == nil {
|
||||||
|
if logf != nil {
|
||||||
|
logf("%d steps of %s left running by a previous process ended", len(ended), node)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if logf != nil {
|
||||||
|
logf("ending the steps left running (try %d of %d): %v", attempt, StaleAttempts, err)
|
||||||
|
}
|
||||||
|
if attempt >= StaleAttempts {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(StaleRetry):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndStaleRound is EndStale as the module's periodic round runs it: at once, and only when the runtime serves (a round
|
||||||
|
// before that is skipped, the start's EndStaleAtStart and the next round cover it). It ends the steps a previous
|
||||||
|
// process left and the asks past delivery.StepBound.
|
||||||
|
func EndStaleRound(scan Scan, node string, logf func(string, ...any)) {
|
||||||
|
select {
|
||||||
|
case <-stdio.Served():
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := EndStale(scan, node, Boot, logf); err != nil && logf != nil {
|
||||||
|
logf("ending the steps left running: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package buildstep
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanOf is the steps a keys double holds, last value per key, its revision the count of its puts; a write through
|
||||||
|
// Update lands only at the revision read, as the state's does. race, when set, runs once before the next Update's check.
|
||||||
|
func scanOf(k *keys, race func(key string)) Scan {
|
||||||
|
return Scan{
|
||||||
|
Keys: func() ([]string, error) {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for key := range k.puts {
|
||||||
|
out = append(out, key)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
},
|
||||||
|
Get: func(key string) (json.RawMessage, uint64, error) {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
s, ok := k.puts[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
raw, err := delivery.EncodeStep(s[len(s)-1])
|
||||||
|
return raw, uint64(len(s)), err
|
||||||
|
},
|
||||||
|
Delete: func(key string, revision uint64) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if uint64(len(k.puts[key])) != revision {
|
||||||
|
return stdio.ErrChanged
|
||||||
|
}
|
||||||
|
delete(k.puts, key)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Update: func(key string, value any, revision uint64) (uint64, error) {
|
||||||
|
if race != nil {
|
||||||
|
race(key)
|
||||||
|
}
|
||||||
|
k.mu.Lock()
|
||||||
|
n := uint64(len(k.puts[key]))
|
||||||
|
k.mu.Unlock()
|
||||||
|
if n != revision {
|
||||||
|
return 0, stdio.ErrChanged
|
||||||
|
}
|
||||||
|
return k.put(key, value)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1890 and 1890/AC6: EndStale ends this node's hand-started running steps of another owner at once, whatever their age,
|
||||||
|
// and deletes its asks of another owner (the stream redelivers them, ADR 0525 §6); of
|
||||||
|
// the steps with no owner (an older build's), the hand-started ones left running before the process began and the asks
|
||||||
|
// past the step bound (a younger ask is the stream's to redeliver); never a step this process owns or a key written
|
||||||
|
// meanwhile. EndOne ends one by hand, never one this process owns or started.
|
||||||
|
func TestEndStaleAndEndOne(t *testing.T) {
|
||||||
|
k := newKeys()
|
||||||
|
boot := time.Now()
|
||||||
|
oldBoot := Boot
|
||||||
|
Boot = boot
|
||||||
|
defer func() { Boot = oldBoot }()
|
||||||
|
gone := ownerOf("b00t", 4242, boot.Add(-time.Hour))
|
||||||
|
put := func(node, id, tool, state string, started time.Time, owner string) {
|
||||||
|
key, _ := delivery.StepKey(node, id)
|
||||||
|
_, _ = k.put(key, json.RawMessage(mustStep(t, delivery.Step{ID: id, Tool: tool, Node: node, State: state, Started: started, Owner: owner})))
|
||||||
|
}
|
||||||
|
put("ace", "old1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), "")
|
||||||
|
put("ace", "mjk3l9x0abcdef", "check ask", delivery.StepRunning, boot.Add(-time.Hour), "")
|
||||||
|
put("ace", "mjk3l9x0abcdee", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), "")
|
||||||
|
put("ace", "mjk3l9x0abcded", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), gone) // note 21's ghost
|
||||||
|
put("ace", "gone2", "build_go_test", delivery.StepRunning, boot.Add(time.Second), gone)
|
||||||
|
put("ace", "mine1", "check ask", delivery.StepRunning, boot.Add(-time.Hour), Owner)
|
||||||
|
put("ace", "raced1", "build_go_test", delivery.StepRunning, boot.Add(-time.Hour), "")
|
||||||
|
put("ace", "new1", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), "")
|
||||||
|
put("ace", "done1", "build_go_fetch", delivery.StepPassed, boot.Add(-time.Hour), gone)
|
||||||
|
put("g14", "other1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), gone)
|
||||||
|
// raced1 is ended by its own run between the read and the write.
|
||||||
|
raced := false
|
||||||
|
scan := scanOf(k, func(key string) {
|
||||||
|
if key == "ace.raced1" && !raced {
|
||||||
|
raced = true
|
||||||
|
put("ace", "raced1", "build_go_test", delivery.StepPassed, boot.Add(-time.Hour), "")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
var said []string
|
||||||
|
ended, err := EndStale(scan, "ace", boot, func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) })
|
||||||
|
if err != nil || len(ended) != 4 {
|
||||||
|
t.Fatalf("ended %v, %v", ended, err)
|
||||||
|
}
|
||||||
|
if _, held := k.puts["ace.mjk3l9x0abcded"]; held {
|
||||||
|
t.Errorf("the ghost ask's key is still held: %+v", k.last("ace.mjk3l9x0abcded"))
|
||||||
|
}
|
||||||
|
for _, key := range []string{"ace.old1", "ace.gone2"} {
|
||||||
|
if s := k.last(key); s.State != delivery.StepFailed || s.Reason != RestartReason || s.Ended.IsZero() {
|
||||||
|
t.Errorf("%s = %+v", key, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := k.last("ace.mjk3l9x0abcdef"); s.State != delivery.StepFailed || !strings.HasPrefix(s.Reason, "past the step bound: its asker stopped waiting at ") {
|
||||||
|
t.Errorf("the old ask = %+v", s)
|
||||||
|
}
|
||||||
|
if log := strings.Join(said, "\n"); !strings.Contains(log, "ace.mjk3l9x0abcded") || !strings.Contains(log, "deleted, left to the stream's redelivery") || !strings.Contains(log, "its owner "+gone+" is not this process "+Owner) {
|
||||||
|
t.Errorf("the log does not name the ghost and its owner:\n%s", log)
|
||||||
|
}
|
||||||
|
for key, state := range map[string]string{"ace.mjk3l9x0abcdee": delivery.StepRunning, "ace.mine1": delivery.StepRunning, "ace.raced1": delivery.StepPassed,
|
||||||
|
"ace.new1": delivery.StepRunning, "ace.done1": delivery.StepPassed, "g14.other1": delivery.StepRunning} {
|
||||||
|
if s := k.last(key); s.State != state {
|
||||||
|
t.Errorf("%s = %s, want %s", key, s.State, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// EndOne: refuses without why, a held step, one this process owns or (with no owner) started, a step not running and
|
||||||
|
// another node's; ends a step a previous process left.
|
||||||
|
for _, c := range []struct{ id, why string }{{"old1", ""}, {"new1", "stuck"}, {"mine1", "stuck"}, {"done1", "x"}, {"g14.other1", "x"}} {
|
||||||
|
if _, err := EndOne(scan, "ace", c.id, c.why, nil); err == nil {
|
||||||
|
t.Errorf("ended %s with why %q", c.id, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put("ace", "old2", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), gone)
|
||||||
|
if _, err := EndOne(scan, "ace", "old2", "x", func(string) bool { return true }); err == nil {
|
||||||
|
t.Error("ended a held step")
|
||||||
|
}
|
||||||
|
if _, err := EndOne(scan, "ace", "ace.old2", "stuck for good", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s := k.last("ace.old2"); s.State != delivery.StepFailed || s.Reason != "stuck for good" || s.Owner != gone {
|
||||||
|
t.Errorf("old2 = %+v", s)
|
||||||
|
}
|
||||||
|
if a, b := ownerOf("b", 1, boot), ownerOf("b", 1, boot.Add(time.Second)); a == b {
|
||||||
|
t.Errorf("two starts of one pid share the owner %q", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustStep(t *testing.T, s delivery.Step) []byte {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := delivery.EncodeStep(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1890: AskWhat names the repository, head and levels of a check ask and the module of a build ask.
|
||||||
|
func TestAskWhat(t *testing.T) {
|
||||||
|
check := delivery.StepAsk{Kind: "check", Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": "0123456789abcdef0123"}, Levels: []string{"unit", "integration"}}}
|
||||||
|
if got, want := AskWhat(check), "check ask, novox/mesh-sdk@0123456789ab, levels unit,integration"; got != want {
|
||||||
|
t.Errorf("check = %q", got)
|
||||||
|
}
|
||||||
|
build := delivery.StepAsk{Kind: "delivery", Build: &delivery.BuildJob{Module: "mesh-x", FP: "abcdef0123456789"}}
|
||||||
|
if got, want := AskWhat(build), "delivery ask, module mesh-x, fp abcdef012345"; got != want {
|
||||||
|
t.Errorf("build = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-126
@@ -1,30 +1,14 @@
|
|||||||
// Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once
|
// Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once
|
||||||
// and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard
|
// and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard
|
||||||
// (issue 1473) and the fetch of one commit over SSH (issue 1474).
|
// (issue 1473), the fetch of one commit over SSH (issue 1474), and the failure contract of a test level with the parsers
|
||||||
|
// of go test -json and node --test's TAP that fill it (failure.go, issue 1901; its conformance kit is package conformance).
|
||||||
package buildstep
|
package buildstep
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Put writes one key of a state: stdio.State(delivery.StepsState).Put's shape (go/state.go).
|
|
||||||
type Put func(key string, value any) (uint64, error)
|
|
||||||
|
|
||||||
// EndAttempts and EndRetry bound how a step's end is written: a write that fails is tried again, since the end
|
|
||||||
// is the only way the step's reader learns it is over.
|
|
||||||
var (
|
|
||||||
EndAttempts = 5
|
|
||||||
EndRetry = 2 * time.Second
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var now = time.Now
|
var now = time.Now
|
||||||
@@ -36,111 +20,3 @@ func NewID() string {
|
|||||||
_, _ = rand.Read(b[:])
|
_, _ = rand.Read(b[:])
|
||||||
return strconv.FormatInt(now().UnixMilli(), 36) + hex.EncodeToString(b[:])
|
return strconv.FormatInt(now().UnixMilli(), 36) + hex.EncodeToString(b[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins a step of tool on node and answers its id at once: the key <node>.<id> holds state running when
|
|
||||||
// Start returns. run is then called in a goroutine of its own, under ctx (the process's, not the tool call's: the
|
|
||||||
// call is cut at 30 s, the step is not). When it returns, the key is written passed with run's result, failed with
|
|
||||||
// the error's text, or refused when the error is a *Refusal; a panic in run ends the step failed, naming it, and
|
|
||||||
// the process goes on. If the running write fails, nothing was started and Start answers the error.
|
|
||||||
func Start(ctx context.Context, put Put, node, tool string, run func(ctx context.Context) (result any, err error)) (string, error) {
|
|
||||||
step, key, err := begin(put, node, tool, NewID())
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
go end(ctx, put, key, step, run)
|
|
||||||
return step.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// begin writes the step running under id.
|
|
||||||
func begin(put Put, node, tool, id string) (delivery.Step, string, error) {
|
|
||||||
step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC()}
|
|
||||||
key, err := delivery.StepKey(node, step.ID)
|
|
||||||
if err != nil {
|
|
||||||
return delivery.Step{}, "", err
|
|
||||||
}
|
|
||||||
if err := write(put, key, step); err != nil {
|
|
||||||
return delivery.Step{}, "", fmt.Errorf("buildstep: writing the running step %s: %w", key, err)
|
|
||||||
}
|
|
||||||
return step, key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// end runs run and writes the step's end.
|
|
||||||
func end(ctx context.Context, put Put, key string, step delivery.Step, run func(ctx context.Context) (any, error)) {
|
|
||||||
result, err := protect(ctx, run)
|
|
||||||
_ = finish(put, key, step, result, err, log.Printf) // finish says a refused end itself
|
|
||||||
}
|
|
||||||
|
|
||||||
// finish writes the step's end from run's result or error: passed, failed or refused (a *Refusal). A write that fails is
|
|
||||||
// tried EndAttempts times. When the whole end is refused every time (a result the state will not take: issue 1930), the
|
|
||||||
// refusal is logged and a small failed end without the result is written instead, naming the write's error, so the
|
|
||||||
// step's reader learns it is over and why. finish answers an error only when no end could be written at all, and then
|
|
||||||
// the step's ask is not acknowledged as done.
|
|
||||||
func finish(put Put, key string, step delivery.Step, result any, err error, logf func(format string, args ...any)) error {
|
|
||||||
step.Ended = now().UTC()
|
|
||||||
switch ref := (*Refusal)(nil); {
|
|
||||||
case err == nil:
|
|
||||||
step.State = delivery.StepPassed
|
|
||||||
if result != nil {
|
|
||||||
raw, merr := json.Marshal(result)
|
|
||||||
if merr != nil {
|
|
||||||
step.State, step.Reason = delivery.StepFailed, "the result is not JSON: "+merr.Error()
|
|
||||||
} else {
|
|
||||||
step.Result = raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case errors.As(err, &ref):
|
|
||||||
step.State, step.Reason = delivery.StepRefused, err.Error()
|
|
||||||
default:
|
|
||||||
step.State, step.Reason = delivery.StepFailed, err.Error()
|
|
||||||
}
|
|
||||||
werr := writeEnd(put, key, step)
|
|
||||||
if werr == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if logf != nil {
|
|
||||||
logf("the end of the step %s (%s) could not be written in %d tries: %v", key, step.State, EndAttempts, werr)
|
|
||||||
}
|
|
||||||
ended := step.State
|
|
||||||
why := fmt.Sprintf("the step ended %s, and its end could not be written: %v", ended, werr)
|
|
||||||
if step.Reason != "" {
|
|
||||||
why = fmt.Sprintf("the step ended %s (%s), and its end could not be written: %v", ended, step.Reason, werr)
|
|
||||||
}
|
|
||||||
step.State, step.Reason, step.Result = delivery.StepFailed, why, nil
|
|
||||||
if ferr := writeEnd(put, key, step); ferr != nil {
|
|
||||||
if logf != nil {
|
|
||||||
logf("the failed end of the step %s could not be written either: %v", key, ferr)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("buildstep: no end of the step %s could be written: %w", key, ferr)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeEnd writes the step's end, trying EndAttempts times.
|
|
||||||
func writeEnd(put Put, key string, step delivery.Step) error {
|
|
||||||
for attempt := 1; ; attempt++ {
|
|
||||||
werr := write(put, key, step)
|
|
||||||
if werr == nil || attempt >= EndAttempts {
|
|
||||||
return werr
|
|
||||||
}
|
|
||||||
time.Sleep(EndRetry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// protect is run with a panic turned into the error that ends the step.
|
|
||||||
func protect(ctx context.Context, run func(ctx context.Context) (any, error)) (result any, err error) {
|
|
||||||
defer func() {
|
|
||||||
if p := recover(); p != nil {
|
|
||||||
result, err = nil, fmt.Errorf("the step panicked: %v", p)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return run(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func write(put Put, key string, step delivery.Step) error {
|
|
||||||
raw, err := delivery.EncodeStep(step)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = put(key, json.RawMessage(raw))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package buildstep
|
package buildstep
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -53,71 +51,6 @@ func (k *keys) waitEnd(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1471/AC1: a run that blocks: Start answers an id within 100 ms and the key holds running.
|
|
||||||
func TestStartAnswersAtOnceWithTheStepRunning(t *testing.T) {
|
|
||||||
k := newKeys()
|
|
||||||
release := make(chan struct{})
|
|
||||||
defer close(release)
|
|
||||||
began := time.Now()
|
|
||||||
id, err := Start(context.Background(), k.put, "ace", "fetch", func(ctx context.Context) (any, error) {
|
|
||||||
<-release
|
|
||||||
return nil, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d := time.Since(began); d > 100*time.Millisecond {
|
|
||||||
t.Errorf("Start took %v", d)
|
|
||||||
}
|
|
||||||
s := k.last("ace." + id)
|
|
||||||
if s.State != delivery.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() {
|
|
||||||
t.Errorf("step = %+v", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1471/AC2 and 1473/AC4: a result, an error and a Refusal end the step passed, failed and refused.
|
|
||||||
func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) {
|
|
||||||
for _, c := range []struct {
|
|
||||||
name string
|
|
||||||
run func(context.Context) (any, error)
|
|
||||||
state string
|
|
||||||
reason string
|
|
||||||
result string
|
|
||||||
}{
|
|
||||||
{"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, delivery.StepPassed, "", `{"sha":"abc"}`},
|
|
||||||
{"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, delivery.StepFailed, "exit 2", ""},
|
|
||||||
{"refusal", func(context.Context) (any, error) {
|
|
||||||
return nil, &Refusal{Text: "go 1.27.1 installed, the recipe builds with go 1.27.0", Want: "1.27.0", Have: "1.27.1"}
|
|
||||||
}, delivery.StepRefused, "go 1.27.1 installed, the recipe builds with go 1.27.0", ""},
|
|
||||||
} {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
k := newKeys()
|
|
||||||
id, err := Start(context.Background(), k.put, "ace", "test", c.run)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := k.waitEnd(t); got != "ace."+id {
|
|
||||||
t.Fatalf("ended key %q", got)
|
|
||||||
}
|
|
||||||
s := k.last("ace." + id)
|
|
||||||
if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() {
|
|
||||||
t.Errorf("step = %+v", s)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1471/AC3: a panic in run ends the step failed, naming it, and the test process goes on.
|
|
||||||
func TestAPanicEndsTheStepFailed(t *testing.T) {
|
|
||||||
k := newKeys()
|
|
||||||
id, _ := Start(context.Background(), k.put, "ace", "build", func(context.Context) (any, error) { panic("boom") })
|
|
||||||
k.waitEnd(t)
|
|
||||||
s := k.last("ace." + id)
|
|
||||||
if s.State != delivery.StepFailed || s.Reason != "the step panicked: boom" {
|
|
||||||
t.Errorf("step = %+v", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1471/AC5: ids made in one and the same millisecond differ, and each is one key token. The clock is fixed
|
// 1471/AC5: ids made in one and the same millisecond differ, and each is one key token. The clock is fixed
|
||||||
// while no step runs, so no goroutine reads it meanwhile.
|
// while no step runs, so no goroutine reads it meanwhile.
|
||||||
func TestTwoIDsInOneMillisecondDiffer(t *testing.T) {
|
func TestTwoIDsInOneMillisecondDiffer(t *testing.T) {
|
||||||
@@ -137,18 +70,3 @@ func TestTwoIDsInOneMillisecondDiffer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A first write that fails starts nothing.
|
|
||||||
func TestStartAnswersTheErrorWhenTheRunningWriteFails(t *testing.T) {
|
|
||||||
ran := make(chan struct{}, 1)
|
|
||||||
_, err := Start(context.Background(), func(string, any) (uint64, error) { return 0, errors.New("no grant") }, "ace", "fetch",
|
|
||||||
func(context.Context) (any, error) { ran <- struct{}{}; return nil, nil })
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Start answered no error")
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ran:
|
|
||||||
t.Error("run was called")
|
|
||||||
case <-time.After(50 * time.Millisecond):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
// The probe a run step's isolation is proved with (novox/hq ADR 0448; issue 1309, S19): run inside the step, it says
|
|
||||||
// on stdout every way it reached what a step must not, and "shut off" when it reached none. Its arguments: the files
|
|
||||||
// it must not read (the forge key, a bus login), a TCP address and a unix socket of the machine it must not reach, a
|
|
||||||
// name it must not resolve, and a cache file it writes, which must be unchanged after the step.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if len(os.Args) != 7 {
|
|
||||||
fmt.Println("usage: probe <key> <login> <tcp address> <unix socket> <name> <cache file>")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
key, login, tcp, socket, name, cache := os.Args[1], os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6]
|
|
||||||
reached := 0
|
|
||||||
say := func(f string, a ...any) { reached++; fmt.Printf(f+"\n", a...) }
|
|
||||||
for _, f := range []string{key, login} {
|
|
||||||
if _, err := os.ReadFile(f); err == nil {
|
|
||||||
say("read %s", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c, err := net.DialTimeout("tcp", tcp, 2*time.Second); err == nil {
|
|
||||||
c.Close()
|
|
||||||
say("reached %s", tcp)
|
|
||||||
}
|
|
||||||
if c, err := net.DialTimeout("unix", socket, 2*time.Second); err == nil {
|
|
||||||
c.Close()
|
|
||||||
say("reached %s", socket)
|
|
||||||
}
|
|
||||||
if addrs, err := net.LookupHost(name); err == nil {
|
|
||||||
say("resolved %s to %v", name, addrs)
|
|
||||||
}
|
|
||||||
if home, err := os.UserHomeDir(); err == nil {
|
|
||||||
if entries, err := os.ReadDir(home); err == nil && len(entries) > 0 {
|
|
||||||
say("the home %s holds %d entries", home, len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(cache, []byte("written by the step\n"), 0o644); err != nil {
|
|
||||||
say("could not write the cache under its throwaway layer: %v", err)
|
|
||||||
}
|
|
||||||
if reached == 0 {
|
|
||||||
fmt.Println("shut off")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
+8
-116
@@ -1,123 +1,15 @@
|
|||||||
package buildstep
|
package buildstep
|
||||||
|
|
||||||
import (
|
// ModuleTree is what a merge holds of one module directory, as a build module's preview answers it (novox/hq ADR 0484
|
||||||
"context"
|
// clause 3, as ADR 0593 §2 changes it): its git tree id, the sha256 of its module.json, the sha256 of each lock file it
|
||||||
"crypto/sha256"
|
// has (go.sum, package-lock.json), and, of a module whose every artifact names its paths, each named path's git object
|
||||||
"encoding/hex"
|
// id at the commit. The indexer alone computes from them (the named tree, the fingerprint); the build module's runner
|
||||||
"fmt"
|
// (mesh-build-go's buildstep.ModuleTrees) fills them.
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ModuleTree is what a merge holds of one module directory: its tree id, the sha256 of its module.json and the sha256
|
|
||||||
// of each lock file it has (go.sum, package-lock.json), from which the checker and the indexer take the module's inputs
|
|
||||||
// (novox/hq ADR 0484 clause 3).
|
|
||||||
type ModuleTree struct {
|
type ModuleTree struct {
|
||||||
Tree string `json:"tree"`
|
Tree string `json:"tree"`
|
||||||
Manifest string `json:"manifest"`
|
Manifest string `json:"manifest"`
|
||||||
Locks map[string]string `json:"locks,omitempty"`
|
Locks map[string]string `json:"locks,omitempty"`
|
||||||
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree
|
Paths map[string]string `json:"paths,omitempty"`
|
||||||
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
|
// Packages are no longer filled (ADR 0593 §2): kept so a preview written before still decodes.
|
||||||
Paths map[string]string `json:"paths,omitempty"`
|
Packages []string `json:"packages,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var lockFiles = []string{"go.sum", "package-lock.json"}
|
|
||||||
|
|
||||||
// ModuleTrees are the trees, in commit, of every directory of the workspace dir that holds a module.json, by directory
|
|
||||||
// (the repository's root is "."); for a module whose every artifact names its paths, the named tree of those paths and
|
|
||||||
// their objects (novox/hq ADR 0499 clause 2). The workspace is at commit, so the manifests and the lock files are read from it. env is
|
|
||||||
// the environment git runs with (nil for none). Both build modules' fetch steps answer it for a merge.
|
|
||||||
func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, commit string) (map[string]ModuleTree, error) {
|
|
||||||
r := run(ctx, env, "git", "-C", dir, "ls-files", "--", "module.json", "*/module.json")
|
|
||||||
if r.Status != 0 || r.Err != "" {
|
|
||||||
return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
|
|
||||||
}
|
|
||||||
trees := map[string]ModuleTree{}
|
|
||||||
for _, l := range strings.Split(r.Stdout, "\n") {
|
|
||||||
if l = strings.TrimSpace(l); l == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mdir := filepath.ToSlash(filepath.Dir(l))
|
|
||||||
spec := commit + ":" + mdir
|
|
||||||
if mdir == "." {
|
|
||||||
spec = commit + "^{tree}"
|
|
||||||
}
|
|
||||||
t := run(ctx, env, "git", "-C", dir, "rev-parse", spec)
|
|
||||||
if t.Status != 0 || t.Err != "" {
|
|
||||||
return nil, fmt.Errorf("git rev-parse %s: %s", spec, firstLine(t.Err+" "+t.Stderr))
|
|
||||||
}
|
|
||||||
mt := ModuleTree{Tree: strings.TrimSpace(t.Stdout)}
|
|
||||||
raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(l)))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
mt.Manifest = sha256Hex(raw)
|
|
||||||
if err := narrow(ctx, run, env, dir, commit, mdir, raw, &mt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, lock := range lockFiles {
|
|
||||||
if raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(mdir), lock)); err == nil {
|
|
||||||
if mt.Locks == nil {
|
|
||||||
mt.Locks = map[string]string{}
|
|
||||||
}
|
|
||||||
mt.Locks[lock] = sha256Hex(raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trees[mdir] = mt
|
|
||||||
}
|
|
||||||
return trees, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// narrow sets mt's Paths and its named Tree when the module's manifest names paths on every artifact (novox/hq ADR 0499
|
|
||||||
// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview reads the manifest here, as
|
|
||||||
// the indexer does, with catalogue.ParseManifestForInputs (issue 1751), and no other build step reads paths from one (ADR 0495 clause 1). A manifest that does not parse
|
|
||||||
// is not narrowed: the indexer gives it none. A named path absent at commit fails, naming the module and the path.
|
|
||||||
func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, mdir string, raw []byte, mt *ModuleTree) error {
|
|
||||||
m, err := catalogue.ParseManifestForInputs(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
paths, every := m.NamedPaths()
|
|
||||||
if !every {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
full := make([]string, len(paths))
|
|
||||||
for i, p := range paths {
|
|
||||||
full[i] = path.Join(mdir, p)
|
|
||||||
}
|
|
||||||
r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, full...)...)
|
|
||||||
if r.Status != 0 || r.Err != "" {
|
|
||||||
return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, firstLine(r.Err+" "+r.Stderr))
|
|
||||||
}
|
|
||||||
objects := map[string]string{}
|
|
||||||
for _, l := range strings.Split(r.Stdout, "\x00") {
|
|
||||||
meta, name, ok := strings.Cut(l, "\t")
|
|
||||||
fields := strings.Fields(meta)
|
|
||||||
if !ok || len(fields) != 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
objects[name] = fields[2]
|
|
||||||
}
|
|
||||||
mt.Paths = map[string]string{}
|
|
||||||
for i, p := range paths {
|
|
||||||
object := objects[full[i]]
|
|
||||||
if object == "" {
|
|
||||||
return fmt.Errorf("the module %s names the path %q, which is not in %s", mdir, p, commit)
|
|
||||||
}
|
|
||||||
mt.Paths[p] = object
|
|
||||||
}
|
|
||||||
named, err := delivery.NamedTree(mt.Manifest, mt.Paths)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("the module %s: %w", mdir, err)
|
|
||||||
}
|
|
||||||
mt.Tree = named
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sha256Hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// On a real repository shaped like mesh-sdk (novox/hq ADR 0499 clauses 2 and 6), a commit touching only the Go package
|
|
||||||
// leaves the TypeScript package's tree value equal, and a commit touching its source changes it; a module that names no
|
|
||||||
// path keeps its directory's tree id.
|
|
||||||
func TestAGoOnlyCommitLeavesTheNamedTreeEqualOnRealGit(t *testing.T) {
|
|
||||||
repo := t.TempDir()
|
|
||||||
realGit(t, repo, "init", "-q", "-b", "main")
|
|
||||||
writeFile(t, repo, "module.json", `{"module":"mesh-sdk","version":"1","build":{"artifacts":[`+
|
|
||||||
`{"name":"lib","kind":"package","language":"typescript","paths":["src","test","package.json","package-lock.json","tsconfig.json"]}]}}`)
|
|
||||||
writeFile(t, repo, "src/index.ts", "export const a = 1\n")
|
|
||||||
writeFile(t, repo, "test/a.test.ts", "// a\n")
|
|
||||||
writeFile(t, repo, "package.json", "{}\n")
|
|
||||||
writeFile(t, repo, "package-lock.json", "{}\n")
|
|
||||||
writeFile(t, repo, "tsconfig.json", "{}\n")
|
|
||||||
writeFile(t, repo, "go/delivery/x.go", "package delivery\n")
|
|
||||||
writeFile(t, repo, "other/module.json", `{"module":"other","version":"1"}`)
|
|
||||||
writeFile(t, repo, "other/a.txt", "a\n")
|
|
||||||
commit := func(msg string) string {
|
|
||||||
realGit(t, repo, "add", "-A")
|
|
||||||
realGit(t, repo, "commit", "-q", "-m", msg)
|
|
||||||
return realGit(t, repo, "rev-parse", "HEAD")
|
|
||||||
}
|
|
||||||
trees := func(c string) map[string]ModuleTree {
|
|
||||||
got, err := ModuleTrees(context.Background(), forgekey.ExecRunner, nil, repo, c)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
first := trees(commit("first"))
|
|
||||||
if !strings.HasPrefix(first["."].Tree, "named:") || len(first["."].Paths) != 5 {
|
|
||||||
t.Fatalf("the root module was not narrowed: %+v", first["."])
|
|
||||||
}
|
|
||||||
if want := realGit(t, repo, "rev-parse", "HEAD:other"); first["other"].Tree != want || first["other"].Paths != nil {
|
|
||||||
t.Fatalf("a module naming no path: %+v, want its directory's tree %s", first["other"], want)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFile(t, repo, "go/delivery/x.go", "package delivery\n\nconst B = 2\n")
|
|
||||||
goOnly := trees(commit("go only"))
|
|
||||||
if goOnly["."].Tree != first["."].Tree {
|
|
||||||
t.Fatalf("a Go-only commit changed the TypeScript package's tree: %s, was %s", goOnly["."].Tree, first["."].Tree)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFile(t, repo, "src/index.ts", "export const a = 2\n")
|
|
||||||
src := trees(commit("src"))
|
|
||||||
if src["."].Tree == first["."].Tree {
|
|
||||||
t.Fatal("a commit to src/ left the TypeScript package's tree equal")
|
|
||||||
}
|
|
||||||
if src["."].Paths["src"] != realGit(t, repo, "rev-parse", "HEAD:src") {
|
|
||||||
t.Fatalf("the object of src is %s, git says %s", src["."].Paths["src"], realGit(t, repo, "rev-parse", "HEAD:src"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module whose every artifact names its paths takes its tree from them (novox/hq ADR 0499 clause 2): ModuleTrees asks
|
|
||||||
// git for each named path's object at the commit, keeps them in Paths and answers their delivery.NamedTree as Tree; a
|
|
||||||
// module naming none keeps its directory's tree, as before.
|
|
||||||
func TestModuleTreesNarrowsAModuleWhoseArtifactsNameTheirPaths(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
sdk := `{"module":"mesh-sdk","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","package.json"]}]}}`
|
|
||||||
writeTreeFile(t, root, "module.json", sdk)
|
|
||||||
writeTreeFile(t, root, "modules/b/module.json", `{"module":"b","version":"1"}`)
|
|
||||||
merge := strings.Repeat("c", 40)
|
|
||||||
var lsTree []string
|
|
||||||
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
|
||||||
switch args[2] {
|
|
||||||
case "ls-files":
|
|
||||||
return forgekey.Ran{Stdout: "module.json\nmodules/b/module.json\n"}
|
|
||||||
case "rev-parse":
|
|
||||||
return forgekey.Ran{Stdout: "tree-of-" + args[3] + "\n"}
|
|
||||||
case "ls-tree":
|
|
||||||
lsTree = append(lsTree, strings.Join(args[4:], " "))
|
|
||||||
return forgekey.Ran{Stdout: "100644 blob " + strings.Repeat("b", 40) + "\tpackage.json\x00" +
|
|
||||||
"040000 tree " + strings.Repeat("a", 40) + "\tsrc\x00"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{Status: 1, Stderr: "unexpected " + args[2]}
|
|
||||||
}
|
|
||||||
got, err := ModuleTrees(context.Background(), run, nil, root, merge)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)}
|
|
||||||
want, _ := delivery.NamedTree(hexOf(sdk), paths)
|
|
||||||
if g := got["."]; g.Tree != want || len(g.Paths) != 2 || g.Paths["src"] != paths["src"] || g.Paths["package.json"] != paths["package.json"] {
|
|
||||||
t.Fatalf("the root module = %+v, want tree %s and paths %v", g, want, paths)
|
|
||||||
}
|
|
||||||
if len(lsTree) != 1 || lsTree[0] != merge+" -- package.json src" {
|
|
||||||
t.Fatalf("git ls-tree ran as %v", lsTree)
|
|
||||||
}
|
|
||||||
if g := got["modules/b"]; g.Tree != "tree-of-"+merge+":modules/b" || g.Paths != nil {
|
|
||||||
t.Fatalf("a module naming no path was narrowed: %+v", g)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A named path absent at the commit fails the answer, naming the module and the path: a tree computed without it would
|
|
||||||
// pin a build source that does not exist.
|
|
||||||
func TestModuleTreesFailsNamingANamedPathAbsentAtTheCommit(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
writeTreeFile(t, root, "modules/a/module.json", `{"module":"a","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","gone"]}]}}`)
|
|
||||||
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
|
||||||
switch args[2] {
|
|
||||||
case "ls-files":
|
|
||||||
return forgekey.Ran{Stdout: "modules/a/module.json\n"}
|
|
||||||
case "rev-parse":
|
|
||||||
return forgekey.Ran{Stdout: "t\n"}
|
|
||||||
case "ls-tree":
|
|
||||||
if strings.Join(args[4:], " ") != strings.Repeat("c", 40)+" -- modules/a/gone modules/a/src" {
|
|
||||||
return forgekey.Ran{Status: 1, Stderr: "wrong arguments " + strings.Join(args[3:], " ")}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{Stdout: "040000 tree " + strings.Repeat("a", 40) + "\tmodules/a/src\x00"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{Status: 1}
|
|
||||||
}
|
|
||||||
_, err := ModuleTrees(context.Background(), run, nil, root, strings.Repeat("c", 40))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), `"gone"`) || !strings.Contains(err.Error(), "modules/a") {
|
|
||||||
t.Fatalf("an absent named path gave %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A manifest whose config value falls back to a predefined key is narrowed here as the indexer narrows it: both read it
|
|
||||||
// with catalogue.ParseManifestForInputs (issue 1751), so a build module holding no published list computes the same
|
|
||||||
// tree (ADR 0499 clause 2).
|
|
||||||
func TestModuleTreesNarrowsAManifestFallingBackToAPredefinedKey(t *testing.T) {
|
|
||||||
was := catalogue.ReplacePublished(nil) // the build modules' case: no published list
|
|
||||||
defer catalogue.ReplacePublished(was)
|
|
||||||
root := t.TempDir()
|
|
||||||
raw := `{"module":"xterm","version":"1","config":{"size":{"type":"number","description":"The text size.","predefined":"font-size","default":12,"runtime":true}},` +
|
|
||||||
`"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src"]}]}}`
|
|
||||||
if _, err := catalogue.ParseManifest([]byte(raw)); err == nil {
|
|
||||||
t.Fatal("ParseManifest reads the manifest with no list; the test proves nothing")
|
|
||||||
}
|
|
||||||
if _, err := catalogue.ParseManifestForInputs([]byte(raw)); err != nil {
|
|
||||||
t.Fatalf("the indexer's read refuses the manifest: %v", err)
|
|
||||||
}
|
|
||||||
writeTreeFile(t, root, "module.json", raw)
|
|
||||||
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
|
||||||
switch args[2] {
|
|
||||||
case "ls-files":
|
|
||||||
return forgekey.Ran{Stdout: "module.json\n"}
|
|
||||||
case "rev-parse":
|
|
||||||
return forgekey.Ran{Stdout: "whole\n"}
|
|
||||||
case "ls-tree":
|
|
||||||
return forgekey.Ran{Stdout: "040000 tree " + strings.Repeat("a", 40) + "\tsrc\x00"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{Status: 1}
|
|
||||||
}
|
|
||||||
got, err := ModuleTrees(context.Background(), run, nil, root, strings.Repeat("c", 40))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want, _ := delivery.NamedTree(hexOf(raw), map[string]string{"src": strings.Repeat("a", 40)})
|
|
||||||
if g := got["."]; g.Tree != want || g.Paths["src"] == "" {
|
|
||||||
t.Fatalf("the module = %+v, want the named tree %s", g, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
func hexOf(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
|
|
||||||
|
|
||||||
func writeTreeFile(t *testing.T, root, name, content string) {
|
|
||||||
t.Helper()
|
|
||||||
p := filepath.Join(root, filepath.FromSlash(name))
|
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModuleTrees answers, for each directory holding a module.json (the root as "."), the tree id git gives, the sha256 of
|
|
||||||
// module.json and of each lock file present, and reads them from the workspace.
|
|
||||||
func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
writeTreeFile(t, root, "module.json", `{"root":1}`)
|
|
||||||
writeTreeFile(t, root, "go.sum", "rootsum")
|
|
||||||
writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`)
|
|
||||||
writeTreeFile(t, root, "modules/a/go.sum", "asum")
|
|
||||||
writeTreeFile(t, root, "modules/a/package-lock.json", "alock")
|
|
||||||
writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`)
|
|
||||||
merge := strings.Repeat("c", 40)
|
|
||||||
var calls []string
|
|
||||||
var envs [][]string
|
|
||||||
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
|
||||||
calls = append(calls, name+" "+strings.Join(args[2:], " "))
|
|
||||||
envs = append(envs, env)
|
|
||||||
switch args[2] {
|
|
||||||
case "ls-files":
|
|
||||||
return forgekey.Ran{Stdout: "module.json\nmodules/a/module.json\nmodules/b/module.json\n"}
|
|
||||||
case "rev-parse":
|
|
||||||
return forgekey.Ran{Stdout: "tree-of-" + args[3] + "\n"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{Status: 1, Stderr: "unexpected"}
|
|
||||||
}
|
|
||||||
got, err := ModuleTrees(context.Background(), run, []string{"X=1"}, root, merge)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := map[string]ModuleTree{
|
|
||||||
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
|
|
||||||
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}},
|
|
||||||
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("trees = %+v\nwant %+v", got, want)
|
|
||||||
}
|
|
||||||
if got["modules/b"].Locks != nil {
|
|
||||||
t.Error("a module with no lock file carries a lock map")
|
|
||||||
}
|
|
||||||
for _, e := range envs {
|
|
||||||
if len(e) != 1 || e[0] != "X=1" {
|
|
||||||
t.Errorf("git ran with env %v, want the one given", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestModuleTreesFailsNamingTheGitCommandOrTheUnreadableManifest(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
fail := func(on string) forgekey.Runner {
|
|
||||||
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
|
||||||
if args[2] == on {
|
|
||||||
return forgekey.Ran{Status: 128, Stderr: "fatal: no\n"}
|
|
||||||
}
|
|
||||||
return forgekey.Ran{Stdout: "modules/a/module.json\n"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, on := range []string{"ls-files", "rev-parse"} {
|
|
||||||
if _, err := ModuleTrees(context.Background(), fail(on), nil, root, sha); err == nil || !strings.Contains(err.Error(), on) || !strings.Contains(err.Error(), "fatal: no") {
|
|
||||||
t.Errorf("%s failing: %v", on, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := ModuleTrees(context.Background(), fail("none"), nil, root, sha); err == nil {
|
|
||||||
t.Error("a module.json that is not in the workspace was read")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
// A build module's room to work and its workspaces (novox/hq issue 1839, ADR 0509): a step is refused while the
|
|
||||||
// filesystem it writes to has less free than the floor, and a workspace is removed when its caller releases it, or,
|
|
||||||
// unheld, once its last use is past a bound (a lost caller's), never while a running step holds it.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FreeSpaceFloor is the free space a build module needs on its work and cache filesystems to take a step: the floor
|
|
||||||
// build-agent keeps (mesh-controller internal/builder/floor.go, issue 1834).
|
|
||||||
const FreeSpaceFloor uint64 = 20 << 30
|
|
||||||
|
|
||||||
// UnheldBound is how long a workspace no step holds is kept after its last use, when its caller never released it
|
|
||||||
// (ADR 0509: a lost caller's workspace goes after two hours).
|
|
||||||
const UnheldBound = 2 * time.Hour
|
|
||||||
|
|
||||||
// FreeBytes reads the bytes an unprivileged writer can still use on the filesystem holding path.
|
|
||||||
func FreeBytes(path string) (uint64, error) {
|
|
||||||
var s syscall.Statfs_t
|
|
||||||
if err := syscall.Statfs(path, &s); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return s.Bavail * uint64(s.Bsize), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Floor refuses a step while a filesystem it writes to has less free than Bytes. A zero Bytes checks nothing.
|
|
||||||
type Floor struct {
|
|
||||||
// Node is named in the refusal.
|
|
||||||
Node string
|
|
||||||
Bytes uint64
|
|
||||||
// Free reads a path's free bytes; FreeBytes when nil. A test answers for a disk it does not have.
|
|
||||||
Free func(path string) (uint64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check answers a *Refusal, "refused: <node> has N GiB free under the floor of F GiB (on <path>)", when any path's
|
|
||||||
// filesystem has less free than the floor, or its free space cannot be read; nil otherwise. A path not made yet is
|
|
||||||
// read at its nearest folder that is; an empty path is skipped.
|
|
||||||
func (f Floor) Check(paths ...string) error {
|
|
||||||
if f.Bytes == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
free := f.Free
|
|
||||||
if free == nil {
|
|
||||||
free = FreeBytes
|
|
||||||
}
|
|
||||||
for _, p := range paths {
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
at := p
|
|
||||||
for {
|
|
||||||
if _, err := os.Stat(at); err == nil || filepath.Dir(at) == at {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
at = filepath.Dir(at)
|
|
||||||
}
|
|
||||||
n, err := free(at)
|
|
||||||
if err != nil {
|
|
||||||
return &Refusal{Text: fmt.Sprintf("refused: the free space of %s on %s cannot be read: %v", p, f.Node, err)}
|
|
||||||
}
|
|
||||||
if n < f.Bytes {
|
|
||||||
return &Refusal{Text: fmt.Sprintf("refused: %s has %d GiB free under the floor of %d GiB (on %s)", f.Node, n>>30, f.Bytes>>30, p),
|
|
||||||
Want: fmt.Sprintf("%d GiB", f.Bytes>>30), Have: fmt.Sprintf("%d GiB", n>>30)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var workspaceID = regexp.MustCompile(`^[a-z0-9]+$`)
|
|
||||||
|
|
||||||
// Workspaces are a build module's workspaces, each <Dir>/<id>, and which of them running steps hold.
|
|
||||||
type Workspaces struct {
|
|
||||||
Dir string
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
held map[string]int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hold marks the workspace held by a running step until the answered func is called; both mark it used now, so the
|
|
||||||
// sweep counts its bound from the step's end. An id that is not a workspace's holds nothing.
|
|
||||||
func (w *Workspaces) Hold(id string) func() {
|
|
||||||
if !workspaceID.MatchString(id) {
|
|
||||||
return func() {}
|
|
||||||
}
|
|
||||||
w.mu.Lock()
|
|
||||||
if w.held == nil {
|
|
||||||
w.held = map[string]int{}
|
|
||||||
}
|
|
||||||
w.held[id]++
|
|
||||||
w.mu.Unlock()
|
|
||||||
w.touch(id)
|
|
||||||
var once sync.Once
|
|
||||||
return func() {
|
|
||||||
once.Do(func() {
|
|
||||||
w.touch(id)
|
|
||||||
w.mu.Lock()
|
|
||||||
if w.held[id]--; w.held[id] <= 0 {
|
|
||||||
delete(w.held, id)
|
|
||||||
}
|
|
||||||
w.mu.Unlock()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *Workspaces) touch(id string) {
|
|
||||||
now := time.Now()
|
|
||||||
_ = os.Chtimes(filepath.Join(w.Dir, id), now, now) // a workspace not made yet has nothing to mark
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release removes the workspace at once: its caller's chain of steps has ended. One already gone is no error; one a
|
|
||||||
// running step holds is refused and kept; an id that is not a workspace's is refused.
|
|
||||||
func (w *Workspaces) Release(id string) error {
|
|
||||||
if !workspaceID.MatchString(id) {
|
|
||||||
return fmt.Errorf("%q is not a workspace id", id)
|
|
||||||
}
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
if w.held[id] > 0 {
|
|
||||||
return fmt.Errorf("the workspace %s is held by a running step", id)
|
|
||||||
}
|
|
||||||
if err := os.RemoveAll(filepath.Join(w.Dir, id)); err != nil {
|
|
||||||
return fmt.Errorf("the workspace %s could not be removed: %w", id, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sweep removes every workspace no step holds whose last use is older than bound, and answers the ids it removed,
|
|
||||||
// sorted. It is the safety net for a caller that never released its workspace.
|
|
||||||
func (w *Workspaces) Sweep(now time.Time, bound time.Duration) []string {
|
|
||||||
entries, err := os.ReadDir(w.Dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
removed := []string{}
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
for _, e := range entries {
|
|
||||||
if !e.IsDir() || !workspaceID.MatchString(e.Name()) || w.held[e.Name()] > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, err := e.Info()
|
|
||||||
if err != nil || now.Sub(info.ModTime()) < bound {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if os.RemoveAll(filepath.Join(w.Dir, e.Name())) == nil {
|
|
||||||
removed = append(removed, e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(removed)
|
|
||||||
return removed
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 1839/AC1: a filesystem under the floor refuses the step, naming the node, what is free and the floor.
|
|
||||||
func TestAFilesystemUnderTheFloorRefusesNamingNodeFreeAndFloor(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
f := Floor{Node: "ace", Bytes: 20 << 30, Free: func(string) (uint64, error) { return 4<<30 + 5, nil }}
|
|
||||||
err := f.Check(dir)
|
|
||||||
var ref *Refusal
|
|
||||||
if !errors.As(err, &ref) {
|
|
||||||
t.Fatalf("Check = %v, want a *Refusal", err)
|
|
||||||
}
|
|
||||||
if want := "refused: ace has 4 GiB free under the floor of 20 GiB (on " + dir + ")"; err.Error() != want {
|
|
||||||
t.Errorf("Check = %q, want %q", err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1839/AC1: at or above the floor, and with no floor set, nothing is refused.
|
|
||||||
func TestAtTheFloorOrWithNoFloorNothingIsRefused(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
if err := (Floor{Node: "ace", Bytes: 20 << 30, Free: func(string) (uint64, error) { return 20 << 30, nil }}).Check(dir); err != nil {
|
|
||||||
t.Errorf("at the floor: %v", err)
|
|
||||||
}
|
|
||||||
if err := (Floor{Node: "ace", Free: func(string) (uint64, error) { return 0, nil }}).Check(dir); err != nil {
|
|
||||||
t.Errorf("no floor: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1839/AC1: a path not made yet is read at its nearest folder that is; a free space that cannot be read is a refusal.
|
|
||||||
func TestAPathNotMadeIsReadAtItsParentAndAnUnreadableOneRefuses(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
var read string
|
|
||||||
f := Floor{Node: "ace", Bytes: 1, Free: func(p string) (uint64, error) { read = p; return 2, nil }}
|
|
||||||
if err := f.Check(filepath.Join(dir, "not", "made")); err != nil || read != dir {
|
|
||||||
t.Errorf("Check = %v, read %q, want nil and %q", err, read, dir)
|
|
||||||
}
|
|
||||||
f.Free = func(string) (uint64, error) { return 0, errors.New("statfs: boom") }
|
|
||||||
var ref *Refusal
|
|
||||||
if err := f.Check(dir); !errors.As(err, &ref) {
|
|
||||||
t.Errorf("Check = %v, want a refusal", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeWorkspace(t *testing.T, w *Workspaces, id string, age time.Duration) string {
|
|
||||||
t.Helper()
|
|
||||||
p := filepath.Join(w.Dir, id)
|
|
||||||
if err := os.MkdirAll(filepath.Join(p, "tree"), 0o700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
at := time.Now().Add(-age)
|
|
||||||
if err := os.Chtimes(p, at, at); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func gone(p string) bool { _, err := os.Stat(p); return os.IsNotExist(err) }
|
|
||||||
|
|
||||||
// 1839/AC2: a released workspace is gone at once; one already gone is no error; one a running step holds is refused
|
|
||||||
// and kept; an id that is not a workspace's is refused.
|
|
||||||
func TestReleaseRemovesAWorkspaceAtOnce(t *testing.T) {
|
|
||||||
w := &Workspaces{Dir: t.TempDir()}
|
|
||||||
p := makeWorkspace(t, w, "abc1", 0)
|
|
||||||
if err := w.Release("abc1"); err != nil || !gone(p) {
|
|
||||||
t.Fatalf("Release = %v, gone %v", err, gone(p))
|
|
||||||
}
|
|
||||||
if err := w.Release("abc1"); err != nil {
|
|
||||||
t.Errorf("a second release = %v, want nil", err)
|
|
||||||
}
|
|
||||||
held := makeWorkspace(t, w, "abc2", 0)
|
|
||||||
done := w.Hold("abc2")
|
|
||||||
if err := w.Release("abc2"); err == nil || gone(held) {
|
|
||||||
t.Errorf("Release of a held workspace = %v, gone %v; want refused and kept", err, gone(held))
|
|
||||||
}
|
|
||||||
done()
|
|
||||||
if err := w.Release("abc2"); err != nil || !gone(held) {
|
|
||||||
t.Errorf("Release after the hold ended = %v, gone %v", err, gone(held))
|
|
||||||
}
|
|
||||||
if err := w.Release("../etc"); err == nil {
|
|
||||||
t.Errorf("Release of ../etc answered nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1839/AC3: the sweep removes a workspace no step holds whose last use is past the bound, and keeps a held one and a
|
|
||||||
// recent one; a hold ending counts as a use.
|
|
||||||
func TestSweepRemovesUnheldWorkspacesPastTheBound(t *testing.T) {
|
|
||||||
w := &Workspaces{Dir: t.TempDir()}
|
|
||||||
old := makeWorkspace(t, w, "old1", 3*time.Hour)
|
|
||||||
heldOld := makeWorkspace(t, w, "held1", 3*time.Hour)
|
|
||||||
recent := makeWorkspace(t, w, "new1", time.Minute)
|
|
||||||
if err := os.WriteFile(filepath.Join(w.Dir, "not-a-workspace.txt"), nil, 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
done := w.Hold("held1")
|
|
||||||
removed := w.Sweep(time.Now(), 2*time.Hour)
|
|
||||||
if !gone(old) || gone(heldOld) || gone(recent) {
|
|
||||||
t.Errorf("after the sweep: old gone %v, held kept %v, recent kept %v", gone(old), !gone(heldOld), !gone(recent))
|
|
||||||
}
|
|
||||||
if len(removed) != 1 || removed[0] != "old1" {
|
|
||||||
t.Errorf("Sweep answered %v, want [old1]", removed)
|
|
||||||
}
|
|
||||||
done()
|
|
||||||
if w.Sweep(time.Now(), 2*time.Hour); gone(heldOld) {
|
|
||||||
t.Errorf("a workspace whose hold just ended was swept: its end is a use")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package buscalls
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Unwrap reads a tool's answer whatever wraps it on its way from the tool runner (the lead's ruling 2026-11-04 13:12:
|
||||||
|
// one reading, not a copy per module): a JSON string holding the answer; the protocol's content list, whose first
|
||||||
|
// text is the answer and whose isError says it failed; the {output, ok, answer} form; the tool runner's {result,
|
||||||
|
// error} envelope, whose error is the call's. It answers the answer itself, the words said with it (the output, the
|
||||||
|
// error, or the answer's own text), and whether the tool says it succeeded. A bare answer is itself, succeeded.
|
||||||
|
func Unwrap(raw json.RawMessage) (answer json.RawMessage, said string, ok bool) {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return Unwrap(json.RawMessage(s))
|
||||||
|
}
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(raw, &m) != nil {
|
||||||
|
return raw, string(raw), true
|
||||||
|
}
|
||||||
|
if content, has := m["content"]; has {
|
||||||
|
var items []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
var e struct {
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &e)
|
||||||
|
if json.Unmarshal(content, &items) == nil && len(items) > 0 {
|
||||||
|
inner, out, ok := Unwrap(json.RawMessage(items[0].Text))
|
||||||
|
return inner, out, ok && !e.IsError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
OK *bool `json:"ok"`
|
||||||
|
Answer json.RawMessage `json:"answer"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &v) == nil {
|
||||||
|
switch {
|
||||||
|
case v.OK != nil:
|
||||||
|
return v.Answer, v.Output, *v.OK
|
||||||
|
case v.Error != "":
|
||||||
|
return nil, v.Error, false
|
||||||
|
case len(v.Result) > 0:
|
||||||
|
return Unwrap(v.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw, string(raw), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
// Package buscalls is the contract of the calls a bundle makes on its own bus state through the tool runner, over the
|
||||||
|
// bundle's pipe (novox/hq ADR 0595 §9, ADR 0597 §10, design 25 § 12.5): the object store's, a stream's read-back and
|
||||||
|
// fill, a work queue's size and dead letters, and the ledger's. Each call names an entry by the local name its module
|
||||||
|
// asks for it by in `requires`; the tool runner serves it from the module's membership's bindings, its own entries
|
||||||
|
// only. Only the shapes and the method names are here: what the tool runner refuses, and why, is its own.
|
||||||
|
//
|
||||||
|
// It is a package of its own, imported only by the modules that use these calls and by the tool runner, so a change
|
||||||
|
// here rebuilds those and not the module library's importers (ADR 0595 §9). It imports nothing of mesh-sdk.
|
||||||
|
package buscalls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetaCall is where in a `tools/call`'s `_meta` the tool runner hands a bundle the id of the tool call in flight: the id
|
||||||
|
// ApplyRequest.Call names, so the runner stamps the caller it proved for that call as the operation's author (novox/hq
|
||||||
|
// ADR 0597 §7). The runner writes `_meta`; a caller writes only `arguments`, so no caller can name another call.
|
||||||
|
const MetaCall = "mesh/call"
|
||||||
|
|
||||||
|
// The methods a bundle asks the tool runner, one per call.
|
||||||
|
const (
|
||||||
|
ObjectsPut = "mesh/objects.put"
|
||||||
|
ObjectsGet = "mesh/objects.get"
|
||||||
|
ObjectsList = "mesh/objects.list"
|
||||||
|
ObjectsDelete = "mesh/objects.delete"
|
||||||
|
ObjectsInfo = "mesh/objects.info"
|
||||||
|
ObjectsWatch = "mesh/objects.watch"
|
||||||
|
|
||||||
|
StreamRead = "mesh/stream.read"
|
||||||
|
StreamFill = "mesh/stream.fill"
|
||||||
|
|
||||||
|
WorkQueueSize = "mesh/workqueue.size"
|
||||||
|
WorkQueueDead = "mesh/workqueue.dead"
|
||||||
|
|
||||||
|
LedgerApply = "mesh/ledger.apply"
|
||||||
|
LedgerGet = "mesh/ledger.get"
|
||||||
|
LedgerHistory = "mesh/ledger.history"
|
||||||
|
LedgerWatch = "mesh/ledger.watch"
|
||||||
|
LedgerQuery = "mesh/ledger.query"
|
||||||
|
// The ledger's repairs, which the tool runner also serves as tools of every module owning a ledger, its own
|
||||||
|
// ledgers only (design 25 § 12.5).
|
||||||
|
LedgerRebuild = "mesh/ledger.rebuild"
|
||||||
|
LedgerReindex = "mesh/ledger.reindex"
|
||||||
|
LedgerSnapshot = "mesh/ledger.snapshot"
|
||||||
|
LedgerLag = "mesh/ledger.lag"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The methods the tool runner sends the bundle for a watch it asked, each naming the watch by its id, as `mesh/log`
|
||||||
|
// does for a log.
|
||||||
|
const (
|
||||||
|
ObjectsWatched = "mesh/objects"
|
||||||
|
LedgerWatched = "mesh/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fill is how much an entry holds against its cap: its messages (or objects), its bytes and its cap in bytes.
|
||||||
|
type Fill struct {
|
||||||
|
Count uint64 `json:"count"`
|
||||||
|
Bytes uint64 `json:"bytes"`
|
||||||
|
MaxBytes uint64 `json:"max_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done is the answer of a call that answers nothing but that it was done.
|
||||||
|
type Done struct{}
|
||||||
|
|
||||||
|
// --- the object store
|
||||||
|
|
||||||
|
// ObjectsPutRequest stores one object whole under its name, replacing one of that name.
|
||||||
|
type ObjectsPutRequest struct {
|
||||||
|
Objects string `json:"objects"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Data []byte `json:"data"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectsNameRequest names one object of an object store: get, delete.
|
||||||
|
type ObjectsNameRequest struct {
|
||||||
|
Objects string `json:"objects"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectsRequest names an object store: list, info.
|
||||||
|
type ObjectsRequest struct {
|
||||||
|
Objects string `json:"objects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectsWatchRequest asks the tool runner to send every object stored or deleted, as ObjectsChange under the watch's id.
|
||||||
|
type ObjectsWatchRequest struct {
|
||||||
|
Objects string `json:"objects"`
|
||||||
|
Watch string `json:"watch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectInfo is one object as its store holds it: put's answer, and each of list's.
|
||||||
|
type ObjectInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size uint64 `json:"size"`
|
||||||
|
Chunks uint32 `json:"chunks"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectData is get's answer: the object's info and its bytes.
|
||||||
|
type ObjectData struct {
|
||||||
|
Info ObjectInfo `json:"info"`
|
||||||
|
Data []byte `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectsListed is list's answer: every object the store holds.
|
||||||
|
type ObjectsListed struct {
|
||||||
|
Objects []ObjectInfo `json:"objects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectsChange is one object stored or deleted, sent under ObjectsWatched; Current says it was there when the watch
|
||||||
|
// began.
|
||||||
|
type ObjectsChange struct {
|
||||||
|
Watch string `json:"watch"`
|
||||||
|
Object ObjectInfo `json:"object"`
|
||||||
|
Current bool `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- a stream
|
||||||
|
|
||||||
|
// StreamReadRequest reads a module's own stream from a sequence on, at most Limit messages, Subjects narrowing it.
|
||||||
|
type StreamReadRequest struct {
|
||||||
|
Stream string `json:"stream"`
|
||||||
|
From uint64 `json:"from"`
|
||||||
|
Subjects string `json:"subjects,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamRequest names a stream: fill.
|
||||||
|
type StreamRequest struct {
|
||||||
|
Stream string `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamMessage is one message of a stream.
|
||||||
|
type StreamMessage struct {
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Body json.RawMessage `json:"body"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamPage is read's answer: the messages in order, the sequence to read on from (0 when nothing follows) and the
|
||||||
|
// stream's last sequence when it was read.
|
||||||
|
type StreamPage struct {
|
||||||
|
Messages []StreamMessage `json:"messages"`
|
||||||
|
Next uint64 `json:"next"`
|
||||||
|
Last uint64 `json:"last"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamFilled is fill's answer: the stream's fill and its first and last sequence.
|
||||||
|
type StreamFilled struct {
|
||||||
|
Fill
|
||||||
|
First uint64 `json:"first"`
|
||||||
|
Last uint64 `json:"last"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- a work queue
|
||||||
|
|
||||||
|
// WorkQueueRequest names a work queue: size.
|
||||||
|
type WorkQueueRequest struct {
|
||||||
|
Queue string `json:"queue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkQueueDeadRequest asks a work queue's dead letters, at most Limit of them.
|
||||||
|
type WorkQueueDeadRequest struct {
|
||||||
|
Queue string `json:"queue"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkQueueSized is size's answer: the messages waiting to be taken, against the queue's cap.
|
||||||
|
type WorkQueueSized struct {
|
||||||
|
Fill
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeadLetter is one message delivered as often as its queue allows and never settled.
|
||||||
|
type DeadLetter struct {
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Deliveries int `json:"deliveries"`
|
||||||
|
Body json.RawMessage `json:"body,omitempty"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkQueueDeadLetters is dead's answer.
|
||||||
|
type WorkQueueDeadLetters struct {
|
||||||
|
Dead []DeadLetter `json:"dead"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the ledger (ADR 0597)
|
||||||
|
|
||||||
|
// ApplyRequest applies one operation to one instance of a ledger. Call is the id of the tool call the operation
|
||||||
|
// serves, as the tool runner handed it under MetaCall, whose caller the tool runner proved and stamps as the author
|
||||||
|
// (ADR 0597 §7); empty, the operation carries the owning module's account alone. An author the bundle writes into Body
|
||||||
|
// is never taken as the author.
|
||||||
|
type ApplyRequest struct {
|
||||||
|
Ledger string `json:"ledger"`
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Body json.RawMessage `json:"body,omitempty"`
|
||||||
|
Call string `json:"call,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applied is apply's answer: the operation was taken, with its sequence in the instance's history; StateBehind says
|
||||||
|
// the state could not follow because its bucket is full, and catches up at the next apply after room is made (ADR
|
||||||
|
// 0597 §6).
|
||||||
|
type Applied struct {
|
||||||
|
Taken bool `json:"taken"`
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
StateBehind bool `json:"state_behind,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author is who an operation is by, as `apply` stamped it (ADR 0597 §7): the owning module's account, and the caller
|
||||||
|
// of the tool call it served, as the tool runner proved it.
|
||||||
|
type Author struct {
|
||||||
|
Account string `json:"account"`
|
||||||
|
Caller string `json:"caller,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceRequest names one instance of a ledger: get, snapshot.
|
||||||
|
type InstanceRequest struct {
|
||||||
|
Ledger string `json:"ledger"`
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceState is get's answer: the instance's state and the sequence of the last operation it holds.
|
||||||
|
type InstanceState struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
State json.RawMessage `json:"state"`
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryRequest reads an instance's operations from a sequence on, at most Limit of them.
|
||||||
|
type HistoryRequest struct {
|
||||||
|
Ledger string `json:"ledger"`
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
From uint64 `json:"from,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operation is one operation as the history keeps it.
|
||||||
|
type Operation struct {
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Body json.RawMessage `json:"body,omitempty"`
|
||||||
|
Author Author `json:"author"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryPage is history's answer: the operations oldest first, the sequence to read on from (0 when nothing
|
||||||
|
// follows) and the instance's last sequence when it was read.
|
||||||
|
type HistoryPage struct {
|
||||||
|
Operations []Operation `json:"operations"`
|
||||||
|
Next uint64 `json:"next"`
|
||||||
|
Last uint64 `json:"last"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerWatchRequest asks the tool runner to send every change of the instances Instances names (an instance, with
|
||||||
|
// `*` for one name and `>` for the rest) as InstanceChange under the watch's id: the current states first.
|
||||||
|
type LedgerWatchRequest struct {
|
||||||
|
Ledger string `json:"ledger"`
|
||||||
|
Instances string `json:"instances"`
|
||||||
|
Watch string `json:"watch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceChange is one instance's new state, sent under LedgerWatched; Current says it was the state when the watch
|
||||||
|
// began.
|
||||||
|
type InstanceChange struct {
|
||||||
|
Watch string `json:"watch"`
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
State json.RawMessage `json:"state"`
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
Current bool `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryRequest reads a declared index of a ledger: the entries whose key Key names (a key, with `*` for one name and
|
||||||
|
// `>` for the rest; empty is every key), at most Limit of them.
|
||||||
|
type QueryRequest struct {
|
||||||
|
Ledger string `json:"ledger"`
|
||||||
|
Index string `json:"index"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexEntry is the value of one key of a declared index (IndexKey): the instance it points at, and the sequence of
|
||||||
|
// the operation it was written from. The owner's watcher and the tool runner's reindex both write it so.
|
||||||
|
type IndexEntry struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Sequence uint64 `json:"sequence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexKey is the key of an instance in a declared index: the values of the index's fields, in its order, then the
|
||||||
|
// parts of the instance's path, all joined by ".". Each value and each path part is encoded (keyToken): every byte
|
||||||
|
// outside [A-Za-z0-9_-] is written "=" and two upper-case hex digits ("." is "=2E", " " is "=20", "=" is "=3D"), so two
|
||||||
|
// different keys never meet and every part is a token the bus takes. `17950.listing.a17` is the key of price 17950
|
||||||
|
// and instance `listing/a17`. The owner's watcher and the tool runner's reindex both write it, so a reindex leaves the
|
||||||
|
// index as the watcher keeps it.
|
||||||
|
func IndexKey(fields []string, instance string) string {
|
||||||
|
parts := make([]string, 0, len(fields)+2)
|
||||||
|
for _, f := range fields {
|
||||||
|
parts = append(parts, keyToken(f))
|
||||||
|
}
|
||||||
|
for _, p := range strings.Split(instance, "/") {
|
||||||
|
parts = append(parts, keyToken(p))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyToken is v with every byte outside [A-Za-z0-9_-] written "=" and two upper-case hex digits (IndexKey).
|
||||||
|
func keyToken(v string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(v); i++ {
|
||||||
|
c := v[i]
|
||||||
|
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' {
|
||||||
|
b.WriteByte(c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "=%02X", c)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryAnswer is query's answer.
|
||||||
|
type QueryAnswer struct {
|
||||||
|
Entries []IndexEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildFrom is what a rebuild derives the state from: the history, or the snapshot and the operations after it.
|
||||||
|
const (
|
||||||
|
RebuildFromHistory = "history"
|
||||||
|
RebuildFromSnapshot = "snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RebuildRequest derives an instance's state again (every instance's when Instance is empty), From the history or the
|
||||||
|
// snapshot.
|
||||||
|
type RebuildRequest struct {
|
||||||
|
Ledger string `json:"ledger"`
|
||||||
|
Instance string `json:"instance,omitempty"`
|
||||||
|
From string `json:"from,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReindexRequest builds a declared index again from the history.
|
||||||
|
type ReindexRequest struct {
|
||||||
|
Ledger string `json:"ledger"`
|
||||||
|
Index string `json:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerRequest names a ledger: lag.
|
||||||
|
type LedgerRequest struct {
|
||||||
|
Ledger string `json:"ledger"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repaired is the answer of rebuild, reindex and snapshot: how many instances (or index entries) it wrote.
|
||||||
|
type Repaired struct {
|
||||||
|
Written int `json:"written"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceLag is one instance whose state or snapshot is behind its history: the sequences each holds.
|
||||||
|
type InstanceLag struct {
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
History uint64 `json:"history"`
|
||||||
|
State uint64 `json:"state"`
|
||||||
|
Snapshot uint64 `json:"snapshot"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexLag is how far one declared index is behind the history.
|
||||||
|
type IndexLag struct {
|
||||||
|
Index string `json:"index"`
|
||||||
|
Behind uint64 `json:"behind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lagged is lag's answer: every instance and index behind its history; none when nothing is behind.
|
||||||
|
type Lagged struct {
|
||||||
|
Instances []InstanceLag `json:"instances,omitempty"`
|
||||||
|
Indexes []IndexLag `json:"indexes,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountsState is the grants part's state of wanted accounts, by bus user.
|
||||||
|
const AccountsState = "accounts"
|
||||||
|
|
||||||
|
// GrantsAccounts is that state as a reader names it (`<owner>.<name>`).
|
||||||
|
const GrantsAccounts = GrantsModule + "." + AccountsState
|
||||||
|
|
||||||
|
// AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash.
|
||||||
|
const AccountIssuedEvent = "account-issued"
|
||||||
|
|
||||||
|
// AccountIssuedSubject is where it says it, as the runtime addresses mesh-vault's events.
|
||||||
|
const AccountIssuedSubject = "mesh.mod." + VaultSeat + ".event." + AccountIssuedEvent
|
||||||
|
|
||||||
|
// ClaimedSeat is one seat a module claims, as its runtime needs it (the controller's seatClaimed): the name, the scope (a
|
||||||
|
// node-scoped seat's verb carries the machine) and the verbs the runtime serves for it.
|
||||||
|
type ClaimedSeat struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Serves []string `json:"serves,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WantedAccount is one key of the grants part's state `accounts`: an account the mesh needs, without its password.
|
||||||
|
type WantedAccount struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
// Kind is KindModule (KindNodeTools for the runtime, named as a module is), KindProcess, KindProvisioner or
|
||||||
|
// KindLedger.
|
||||||
|
Kind Kind `json:"kind"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
// Process is a KindProcess account's process id.
|
||||||
|
Process string `json:"process,omitempty"`
|
||||||
|
// URL is the bus's address as that node reaches it, `nats://<host>:<port>`; Fingerprint the bus certificate's.
|
||||||
|
URL string `json:"url"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
// Claims are a module's claims with the verbs each seat promises: none for a process or a provisioner client.
|
||||||
|
Claims []ClaimedSeat `json:"claims,omitempty"`
|
||||||
|
// At is when the grants part wrote the key: not compared (Same).
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgersSecret is the tool runner's own secret holding its machine's ledger accounts, a map of module to credential
|
||||||
|
// (ADR 0597 §9).
|
||||||
|
const LedgersSecret = "ledgers"
|
||||||
|
|
||||||
|
// mintedKinds are the kinds of account the vault mints (ADR 0561 §1).
|
||||||
|
var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner, KindLedger}
|
||||||
|
|
||||||
|
// principal is the account as the composer names it.
|
||||||
|
func (w WantedAccount) principal() Principal {
|
||||||
|
return Principal{Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid says why w is not an account the vault may mint, or nil: a kind it mints, every name a subject token, the user
|
||||||
|
// the one the composer derives from them, and an address to reach the bus at.
|
||||||
|
func (w WantedAccount) Valid() error {
|
||||||
|
if !slices.Contains(mintedKinds, w.Kind) {
|
||||||
|
return fmt.Errorf("%s is a %s account, which the vault never mints (ADR 0561 §4)", w.User, w.Kind)
|
||||||
|
}
|
||||||
|
for what, v := range map[string]string{"node": w.Node, "module": w.Module} {
|
||||||
|
if !safeSubject.MatchString(v) {
|
||||||
|
return fmt.Errorf("%s: the %s %q is not a name the mesh writes", w.User, what, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.Kind == KindProcess && !safeSubject.MatchString(w.Process) {
|
||||||
|
return fmt.Errorf("%s: the process %q is not a name the mesh writes", w.User, w.Process)
|
||||||
|
}
|
||||||
|
if w.Kind != KindProcess && w.Process != "" {
|
||||||
|
return fmt.Errorf("%s: only a process's account names a process", w.User)
|
||||||
|
}
|
||||||
|
if want := w.principal().Username(); w.User != want {
|
||||||
|
return fmt.Errorf("%s is not the user of a %s account of %s on %s (that is %s)", w.User, w.Kind, w.Module, w.Node, want)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(w.URL, "nats://") || len(w.URL) == len("nats://") {
|
||||||
|
return fmt.Errorf("%s names no bus address (url, nats://<host>:<port>)", w.User)
|
||||||
|
}
|
||||||
|
if len(w.Claims) > 0 && w.Kind != KindModule && w.Kind != KindNodeTools {
|
||||||
|
return fmt.Errorf("%s: a %s account holds no seat, so it carries no claims", w.User, w.Kind)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same says whether two wanted accounts want the same account: every field but when it was written.
|
||||||
|
func (w WantedAccount) Same(o WantedAccount) bool {
|
||||||
|
a, b := w, o
|
||||||
|
a.At, b.At = time.Time{}, time.Time{}
|
||||||
|
ra, _ := json.Marshal(a)
|
||||||
|
rb, _ := json.Marshal(b)
|
||||||
|
return string(ra) == string(rb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnSecret is the module's own secret the account is held in: `broker`, `broker-<process id>` or `provisioner-broker`
|
||||||
|
// (the controller's names, ADR 0436 §2, ADR 0349 §11); a ledger account in the tool runner's `ledgers`, which keeps every
|
||||||
|
// ledger account of its machine, by module (ADR 0597 §9).
|
||||||
|
func (w WantedAccount) OwnSecret() string {
|
||||||
|
switch w.Kind {
|
||||||
|
case KindLedger:
|
||||||
|
return LedgersSecret
|
||||||
|
case KindProcess:
|
||||||
|
return "broker-" + w.Process
|
||||||
|
case KindProvisioner:
|
||||||
|
return "provisioner-broker"
|
||||||
|
}
|
||||||
|
return "broker"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadWantedAccount reads the key user of the state: refused when it is not a valid wanted account of that user.
|
||||||
|
func ReadWantedAccount(user string, raw []byte) (WantedAccount, error) {
|
||||||
|
var w WantedAccount
|
||||||
|
if err := json.Unmarshal(raw, &w); err != nil {
|
||||||
|
return WantedAccount{}, fmt.Errorf("%s %s is not a wanted account: %w", GrantsAccounts, user, err)
|
||||||
|
}
|
||||||
|
if w.User != user {
|
||||||
|
return WantedAccount{}, fmt.Errorf("%s %s names the user %q", GrantsAccounts, user, w.User)
|
||||||
|
}
|
||||||
|
return w, w.Valid()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountIssued is the vault's account-issued: which account it minted, and its generation. Never a value or a hash.
|
||||||
|
type AccountIssued struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
Kind Kind `json:"kind"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Process string `json:"process,omitempty"`
|
||||||
|
Generation int `json:"generation"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeldCredential is the value the account's holder reads, exactly as the controller builds it today (issueWith,
|
||||||
|
// sealProcess and sealProvisionerClient): a module's `{url, fingerprint, node, module, user, password, claims}`, a
|
||||||
|
// process's without claims, a provisioner client's without module or claims; an empty fingerprint or claims left out.
|
||||||
|
func HeldCredential(w WantedAccount, password string) ([]byte, error) {
|
||||||
|
if err := w.Valid(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
return nil, fmt.Errorf("%s: an account is held with its password", w.User)
|
||||||
|
}
|
||||||
|
switch w.Kind {
|
||||||
|
case KindProcess:
|
||||||
|
return json.Marshal(struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password})
|
||||||
|
case KindProvisioner:
|
||||||
|
return json.Marshal(struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, User: w.User, Password: password})
|
||||||
|
}
|
||||||
|
return json.Marshal(struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Claims []ClaimedSeat `json:"claims,omitempty"`
|
||||||
|
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password,
|
||||||
|
Claims: w.Claims})
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeclarationFilesBucket is the prefix of the buckets' names, which mesh-sdk fixes for the node-engine and the controller alike.
|
||||||
|
const DeclarationFilesBucket = declaration.FileBucketPrefix
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
// A Consumer is a durable subscription the controller creates on a module's behalf. A module
|
||||||
|
// declares what it reacts to, never how delivery works, so it does not name these and cannot
|
||||||
|
// misconfigure them.
|
||||||
|
type Consumer struct {
|
||||||
|
Name string
|
||||||
|
Stream string
|
||||||
|
// Filters are the subjects this consumer receives. One consumer per module with several
|
||||||
|
// filters, rather than one per consumed event: its ack subject is derived from its name, and
|
||||||
|
// a module with five consumers would need five ack permissions to ack its own deliveries.
|
||||||
|
Filters []string
|
||||||
|
// Queue is the queue group, set for a seat's worker so that "exactly one holder" survives a
|
||||||
|
// seat later being relaxed to several. Authority and delivery are kept separate on purpose.
|
||||||
|
Queue string
|
||||||
|
// Push asks the server to deliver to a subject rather than wait to be pulled.
|
||||||
|
//
|
||||||
|
// For the mesh's own consumer, where the controller wants every message to arrive in the one
|
||||||
|
// loop it already runs: pulling would mean a second goroutine fetching batches and handing
|
||||||
|
// them over, and a loop that acts on one message at a time is the property the store window
|
||||||
|
// depends on. A queue group implies this, because a group has nothing to pull from.
|
||||||
|
Push bool
|
||||||
|
// TakesOverFrom names a consumer of the same stream whose filter this one takes over (issue 973): made while
|
||||||
|
// that consumer still filters this one's subject, it starts after what that consumer has acknowledged, so a
|
||||||
|
// message the old one held unacknowledged is not lost to the change. Otherwise it starts as FromNow says.
|
||||||
|
TakesOverFrom string
|
||||||
|
// DeliverSubject, when set, is where a push consumer delivers instead of DeliverSubjectFor's.
|
||||||
|
DeliverSubject string
|
||||||
|
// AckWaitSeconds before an unacknowledged delivery is redelivered.
|
||||||
|
AckWaitSeconds int
|
||||||
|
// MaxDeliver is how often a message is handed over before the consumer gives it up; zero for no
|
||||||
|
// bound. What a consumer gives up is kept in DEAD_LETTERS by the controller (novox/hq issue 330).
|
||||||
|
MaxDeliver int
|
||||||
|
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
|
||||||
|
// the server's default, which is many. **One, for a consumer handled one at a time**
|
||||||
|
// (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a
|
||||||
|
// heartbeat, but everything handed over behind it times out unacknowledged and comes back —
|
||||||
|
// and a merge that came back rebuilt what it had just built, five times over on 2026-09-30.
|
||||||
|
// With one outstanding, the server holds the rest, and the heartbeat is keeping the message.
|
||||||
|
MaxAckPending int
|
||||||
|
// FromNow makes a consumer that does not exist yet start at the stream's end rather than its
|
||||||
|
// beginning (novox/hq issue 248). For a consumer that reacts to announcements — a merge, a build's
|
||||||
|
// outcome — on a stream that keeps a week of them: the server's default, everything the stream
|
||||||
|
// holds, replays every merge and every build of that week as if it had just happened. A consumer
|
||||||
|
// that exists keeps where it is, whatever this says; only its making is decided here.
|
||||||
|
FromNow bool
|
||||||
|
Why string
|
||||||
|
// Resettable says why this consumer may be re-made to deliver from now by the mesh itself, with
|
||||||
|
// nobody asked (novox/hq to-be 45 §7, healer H4): what a reset drops, something else catches up.
|
||||||
|
// Empty for every consumer where nothing would — a module's, whose events would be lost to it.
|
||||||
|
// Not a property of the consumer on the bus: nothing here is sent to the server.
|
||||||
|
Resettable string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleConsumer is one module's durable consumer, with the module and node it is for.
|
||||||
|
type ModuleConsumer struct {
|
||||||
|
Node, Module string
|
||||||
|
Consumer Consumer
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package buscompose is the bus's names and shapes every part reads (novox/hq ADR 0593 §1): the mesh's streams and
|
||||||
|
// dead letters, a module's log, stream and buckets as the bus names them, the account a part asks for, a principal's
|
||||||
|
// shape, and the grants part's memberships key. The composer of the bus's user list, which reads these and makes every
|
||||||
|
// user's permissions, is the grants part's own (mesh-delivery modules/mesh-grants internal/buscompose).
|
||||||
|
package buscompose
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
// EngineModule is the node-engine's manifest's module name, and EngineActsState its state the acts asked through the
|
||||||
|
// node seat and its own asks are kept in (ADR 0563 §2, §3: "the engine's own state"): the bucket mesh-host_acts, keys
|
||||||
|
// `<node>.act.<id>`, `<node>.open.<act>.<digest>` and `<node>.ask.<id>`, each written by that node's engine alone.
|
||||||
|
const (
|
||||||
|
EngineModule = "mesh-host"
|
||||||
|
EngineActsState = "acts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EngineActsBucket is that state's bucket.
|
||||||
|
var EngineActsBucket = BucketName(EngineModule, EngineActsState)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ControllerLeftoverConsumers are the ended controller's durable consumers left on the bus, one leftover of the module
|
||||||
|
// mesh-delivery placed nowhere (issue 1126, milestone section 3), and mesh-catalog's of the controller's built-before,
|
||||||
|
// which its manifest no longer consumes and no part composes; each `<stream>.<consumer>`.
|
||||||
|
var ControllerLeftoverConsumers = []string{
|
||||||
|
"EVENTS." + ControllerName, "EVENTS." + MergesConsumerName, "EVENTS.novox_mesh-delivery", "EVENTS.novox_mesh-catalog",
|
||||||
|
"CONTROL." + ControllerName, DeadLetterNoticesStream + "." + ControllerName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControllerLeftoverConsumersOn are ControllerLeftoverConsumers and, for each node given, the consumer the controller
|
||||||
|
// made for the node's engine (`EVENTS.<node>_mesh-host`, of restic's backed-up): the engine hears that event without a
|
||||||
|
// durable consumer, and the grants part composes none for it, so nothing reads it (issue 1126).
|
||||||
|
func ControllerLeftoverConsumersOn(nodes []string) []string {
|
||||||
|
out := slices.Clone(ControllerLeftoverConsumers)
|
||||||
|
for _, n := range nodes {
|
||||||
|
if safeSubject.MatchString(n) {
|
||||||
|
out = append(out, "EVENTS."+n+"_"+EngineModule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// endedController is the name the ended controller had as a module: its buckets are named for it (ADR 0590 §6).
|
||||||
|
const endedController = "mesh-controller"
|
||||||
|
|
||||||
|
// ControllerLeftoverBuckets are the ended controller's buckets, each by its local name (`KV_mesh-controller_<name>`).
|
||||||
|
var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "calls", "checks-running", "composed",
|
||||||
|
"condition-history", "conditions", "declared-config", "declared-packages", "delivery-merges", "grants", "hand-acts",
|
||||||
|
"lease", "predefined", "rollouts", "states-announced"}
|
||||||
|
|
||||||
|
// ControllerLeftoverStreams are the ended controller's streams, by their names: CONTROL, ASSIGNMENTS, each of its buckets'
|
||||||
|
// and, for each node given, the declaration files' object bucket the controller wrote (ADR 0486 §2).
|
||||||
|
func ControllerLeftoverStreams(nodes []string) []string {
|
||||||
|
out := []string{"CONTROL", AssignmentsStream}
|
||||||
|
for _, b := range ControllerLeftoverBuckets {
|
||||||
|
out = append(out, "KV_"+BucketName(endedController, b))
|
||||||
|
}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if safeSubject.MatchString(n) {
|
||||||
|
out = append(out, "OBJ_"+DeclarationFilesBucket+n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, ADR 0590 clause
|
||||||
|
// 6), each removal its grant: the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
|
||||||
|
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; and
|
||||||
|
// the ended controller's leftovers, each consumer and each stream read and deleted by its name, for the nodes given (the
|
||||||
|
// declaration files' object buckets). A removal outside the set is refused, as the indexer's judge refuses it in a
|
||||||
|
// manifest (novox/hq ADR 0578 §1).
|
||||||
|
func BusRemovalGrants(removes, nodes []string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, r := range removes {
|
||||||
|
switch r {
|
||||||
|
case catalogue.RemovesEngineActs:
|
||||||
|
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
|
||||||
|
case catalogue.RemovesControllerLeftovers:
|
||||||
|
for _, c := range ControllerLeftoverConsumersOn(nodes) {
|
||||||
|
out = append(out, "$JS.API.CONSUMER.INFO."+c, "$JS.API.CONSUMER.DELETE."+c)
|
||||||
|
}
|
||||||
|
for _, s := range ControllerLeftoverStreams(nodes) {
|
||||||
|
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.DELETE."+s)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4, ADR 0590)", r,
|
||||||
|
strings.Join([]string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
|
||||||
|
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
|
||||||
|
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
|
||||||
|
// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit
|
||||||
|
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
|
||||||
|
// them (the lead, 2026-10-29).
|
||||||
|
func WatchmanHears() []string {
|
||||||
|
return []string{
|
||||||
|
"mesh.mod.power.event.*",
|
||||||
|
"mesh.mod.*.event.provisioner.failing",
|
||||||
|
"mesh.mod.*.event.provisioner.recovered",
|
||||||
|
"mesh.control.*.tools-alive",
|
||||||
|
"mesh.seat.mesh-self-check.event." + health.EventChecked,
|
||||||
|
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
|
||||||
|
"mesh.mod.nats.event.bus.slow-consumer",
|
||||||
|
"mesh.mod.nats.event.bus.connection-closed",
|
||||||
|
ProvAppliedSubjects,
|
||||||
|
deadLetterPrefix + ">",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mesh's caps on a log: what an entry's value may weigh, what a message on the log's stream may
|
||||||
|
// weigh — the value and its headers, which the server counts in a message's size, so a value of the
|
||||||
|
// full size still fits with the expected-last-sequence header an append carries — and what a log holds
|
||||||
|
// when its module says nothing and at most.
|
||||||
|
const (
|
||||||
|
LogMaxEntryBytes = 256 * 1024
|
||||||
|
LogMaxMessageBytes = LogMaxEntryBytes + 4*1024
|
||||||
|
LogDefaultMiB = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Log is one module's declared log as the bus holds it.
|
||||||
|
type Log struct {
|
||||||
|
Module string
|
||||||
|
Name string
|
||||||
|
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
|
||||||
|
MaxMiB int
|
||||||
|
// MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration).
|
||||||
|
MaxAgeDays int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
|
||||||
|
const LogStreamPrefix = "LOG_"
|
||||||
|
|
||||||
|
// LogStreamName is the stream a module's log lives in: `LOG_<module>_<name>`. The module and the
|
||||||
|
// local name are each one token with no underscore, so two modules can never derive one stream.
|
||||||
|
func LogStreamName(module, name string) string { return LogStreamPrefix + module + "_" + name }
|
||||||
|
|
||||||
|
// LogSubject is the subject a log's entries are published under, without the key: an entry for key K
|
||||||
|
// is on `<LogSubject>.<K>`.
|
||||||
|
func LogSubject(module, name string) string { return "mesh.log." + module + "." + name }
|
||||||
|
|
||||||
|
// Stream is this log's stream name.
|
||||||
|
func (l Log) Stream() string { return LogStreamName(l.Module, l.Name) }
|
||||||
|
|
||||||
|
// Subject is this log's subject, without the key.
|
||||||
|
func (l Log) Subject() string { return LogSubject(l.Module, l.Name) }
|
||||||
|
|
||||||
|
// MaxBytes is this log's cap in bytes.
|
||||||
|
func (l Log) MaxBytes() int64 {
|
||||||
|
mib := l.MaxMiB
|
||||||
|
if mib <= 0 {
|
||||||
|
mib = LogDefaultMiB
|
||||||
|
}
|
||||||
|
return int64(mib) * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why is carried into the server's description of the stream, so somebody reading the server's own
|
||||||
|
// state finds whose it is and why it is kept.
|
||||||
|
func (l Log) Why() string {
|
||||||
|
return fmt.Sprintf("%s's log %q (novox/hq ADR 0297): its record of operations, one entry per operation "+
|
||||||
|
"under its key, appended by %s alone and kept as long as the log; never removed by the mesh, because "+
|
||||||
|
"it is data", l.Module, l.Name, l.Module)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
|
||||||
|
// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of
|
||||||
|
// headers at most, that refuses a new entry when full rather than
|
||||||
|
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
|
||||||
|
// is how the runtime reads it.
|
||||||
|
func (l Log) Config() jetstream.StreamConfig {
|
||||||
|
return jetstream.StreamConfig{
|
||||||
|
Name: l.Stream(),
|
||||||
|
Description: l.Why(),
|
||||||
|
Subjects: []string{l.Subject() + ".>"},
|
||||||
|
Storage: jetstream.FileStorage,
|
||||||
|
Retention: jetstream.LimitsPolicy,
|
||||||
|
MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour,
|
||||||
|
MaxMsgs: -1,
|
||||||
|
MaxMsgsPerSubject: -1,
|
||||||
|
MaxBytes: l.MaxBytes(),
|
||||||
|
MaxMsgSize: LogMaxMessageBytes,
|
||||||
|
Discard: jetstream.DiscardNew,
|
||||||
|
AllowDirect: true,
|
||||||
|
DenyDelete: true,
|
||||||
|
DenyPurge: true,
|
||||||
|
Replicas: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// moduleStreamPrefix starts every subject a module's own stream takes.
|
||||||
|
const moduleStreamPrefix = "mesh.stream."
|
||||||
|
|
||||||
|
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
|
||||||
|
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
|
||||||
|
func ModuleStreamSubject(module, event string) string {
|
||||||
|
return moduleStreamPrefix + module + ".event." + event
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ModuleStream is one module's declared stream as the bus holds it.
|
||||||
|
type ModuleStream struct {
|
||||||
|
Module string
|
||||||
|
// Name is its name on the bus, `MAIL`.
|
||||||
|
Name string
|
||||||
|
// Events are the events it carries, as the bus spells them (WirePattern).
|
||||||
|
Events []string
|
||||||
|
MaxAgeDays int
|
||||||
|
MaxMiB int
|
||||||
|
// Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4).
|
||||||
|
Restricted bool
|
||||||
|
// WorkQueue says this stream holds ADR 0436 §6's work-queue retention: a message is removed once a
|
||||||
|
// consumer acknowledges it, and the bus refuses a new one when full rather than dropping its oldest.
|
||||||
|
WorkQueue bool
|
||||||
|
// Duplicates is the work-queue's duplicate window in hours (novox/hq ADR 0436 §6); meaningless unless
|
||||||
|
// WorkQueue.
|
||||||
|
Duplicates int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subjects are the subjects the stream takes.
|
||||||
|
func (s ModuleStream) Subjects() []string {
|
||||||
|
var out []string
|
||||||
|
for _, e := range s.Events {
|
||||||
|
out = append(out, ModuleStreamSubject(s.Module, e))
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
|
||||||
|
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
|
||||||
|
// answers for anything older.
|
||||||
|
func (s ModuleStream) Stream() Stream {
|
||||||
|
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
|
||||||
|
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
|
||||||
|
if s.Restricted {
|
||||||
|
why += "; heard only by the consumers its trusted config value grants"
|
||||||
|
}
|
||||||
|
if s.WorkQueue {
|
||||||
|
why += "; a work-queue (novox/hq ADR 0436 §6): a message is removed once its one consumer " +
|
||||||
|
"acknowledges it, and a full stream refuses a new one rather than dropping what it holds"
|
||||||
|
}
|
||||||
|
out := Stream{
|
||||||
|
Name: s.Name,
|
||||||
|
Subjects: s.Subjects(),
|
||||||
|
Retention: RetentionLimits,
|
||||||
|
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
|
||||||
|
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
|
||||||
|
Why: why,
|
||||||
|
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
|
||||||
|
Restricted: s.Restricted,
|
||||||
|
}
|
||||||
|
if s.WorkQueue {
|
||||||
|
out.Retention = RetentionWorkQueue
|
||||||
|
out.DiscardNew = true
|
||||||
|
out.DuplicatesSeconds = s.Duplicates * 60 * 60
|
||||||
|
// Said on the bus too, reusing the one flag a restricted stream's dead letters already age and hide
|
||||||
|
// by (novox/hq ADR 0436 §6, ADR 0376 §3-4): a work-queue stream's grant is always empty (ADR 0436
|
||||||
|
// §6 refuses one that carries a grant), so this never collides with a trusted-config restriction.
|
||||||
|
out.Restricted = true
|
||||||
|
// And its own metadata, apart from the bus's work-queue retention SEAT_* and other mesh-internal
|
||||||
|
// streams share without being one of this clause's: only this flag says a dead letter kept from the
|
||||||
|
// stream is drop-only (novox/hq ADR 0436 §6), never the stream's Retention alone.
|
||||||
|
out.WorkQueue = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueOwner is the one process of a module that is the durable consumer of one of its own work-queue
|
||||||
|
// streams (novox/hq ADR 0436 §1, §2, §6): named in that process's bus.queues, and checked at registration
|
||||||
|
// to be the only one (the indexer's judge, novox/hq ADR 0578 §1).
|
||||||
|
type QueueOwner struct {
|
||||||
|
// Process is the process resource's id — its bus account's name, with the node and module (novox/hq ADR
|
||||||
|
// 0436 §2).
|
||||||
|
Process string
|
||||||
|
// Stream is the work-queue stream's name on the bus.
|
||||||
|
Stream string
|
||||||
|
// MaxDeliver bounds how many times a message is handed over before it is given up as a dead letter
|
||||||
|
// (novox/hq ADR 0436 §6).
|
||||||
|
MaxDeliver int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
|
||||||
|
// within the stream and, for a restricted stream, within what the provider's trusted config value grants it
|
||||||
|
// (ADR 0363 §5). Composed only with at least one subject.
|
||||||
|
type Heard struct {
|
||||||
|
Stream string
|
||||||
|
Emitter string
|
||||||
|
Filters []string
|
||||||
|
// Restricted says the stream is restricted by its provider's trusted config value (ADR 0363 §4): then it
|
||||||
|
// is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears).
|
||||||
|
Restricted bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The names this package keeps for the grants part's composer (mesh-grants internal/buscompose, ADR 0593 §1) agree with
|
||||||
|
// what the runtime reads: the memberships bucket, and a log's stream and subject.
|
||||||
|
func TestTheBusNamesAgree(t *testing.T) {
|
||||||
|
if GrantsMembershipsBucket != membership.GrantsBucket {
|
||||||
|
t.Fatalf("the memberships bucket is %q here and %q in membership", GrantsMembershipsBucket, membership.GrantsBucket)
|
||||||
|
}
|
||||||
|
if MembershipKey("ace", "netcheck") != "ace.netcheck" {
|
||||||
|
t.Fatal("a membership's key is not <node>.<module>")
|
||||||
|
}
|
||||||
|
if LogStreamName("mesh-delivery-builder", "build-log") == "" || LogSubject("mesh-delivery-builder", "build-log") == "" {
|
||||||
|
t.Fatal("a log has no stream or subject")
|
||||||
|
}
|
||||||
|
// 1966/AC20: a ledger's history and snapshot subjects are one contract, which the grants part grants a ledger
|
||||||
|
// account its publish on.
|
||||||
|
if h, sn := LedgerHistorySubject("m", "records"), LedgerSnapshotSubject("m", "records"); h != "mesh.ledger.m.records.history" ||
|
||||||
|
sn != "mesh.ledger.m.records.snapshot" {
|
||||||
|
t.Fatalf("a ledger's subjects are %q and %q", h, sn)
|
||||||
|
}
|
||||||
|
// 1966/AC23: a wanted account of kind ledger for m on a names the user ledger.a.m and the own secret ledgers.
|
||||||
|
ledger := WantedAccount{User: "ledger.a.m", Kind: KindLedger, Node: "a", Module: "m", URL: "nats://10.0.0.1:4222"}
|
||||||
|
if got := (Principal{Kind: KindLedger, Node: "a", Module: "m"}).Username(); got != "ledger.a.m" {
|
||||||
|
t.Fatalf("a ledger account is named %q", got)
|
||||||
|
}
|
||||||
|
if err := ledger.Valid(); err != nil || ledger.OwnSecret() != "ledgers" {
|
||||||
|
t.Fatalf("a wanted ledger account: %v, held in %q", err, ledger.OwnSecret())
|
||||||
|
}
|
||||||
|
// A seat's family is the shared events stream filtered to its subjects: a definition says the stream's age.
|
||||||
|
for _, st := range MeshStreams() {
|
||||||
|
if st.Name == EventsStream && int64(st.MaxAge) != seatdef.SharedEventsMaxAgeDays*24*60*60 {
|
||||||
|
t.Errorf("the shared events stream keeps %d s, a seat's definition says %d days", st.MaxAge, seatdef.SharedEventsMaxAgeDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
// Composing the bus's own configuration.
|
||||||
|
//
|
||||||
|
// An account is *composed*, never called for: the controller writes accounts, users and
|
||||||
|
// per-subject permissions into one file the host keeps current, and the server reloads it in
|
||||||
|
// place (novox/hq ADR 0106 — never through a management API; design 25 §4).
|
||||||
|
//
|
||||||
|
// Everything here is pure. Given the principals, it returns the file's text — so the whole of the
|
||||||
|
// mesh's authority model is testable as strings, with no server.
|
||||||
|
//
|
||||||
|
// **Permissions are per subject, so a module's own name is the server's to enforce.** ADR 0042
|
||||||
|
// reserves a module's origin — it publishes only under its own name — and here that is a refusal
|
||||||
|
// rather than something a library promises.
|
||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Kind is what a principal is, which decides the shape of its authority rather than its
|
||||||
|
// contents: a module's comes from its declaration, a host's from its node, and the controller's
|
||||||
|
// and the enrolment user's are fixed.
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindModule Kind = "module"
|
||||||
|
KindNode Kind = "node"
|
||||||
|
KindEnrolment Kind = "enrolment"
|
||||||
|
// KindPerson is somebody reaching the mesh's tools from a workstation (design 25 §7). Its
|
||||||
|
// authority is a list of tools and nothing else — not control, not declarations, not builds,
|
||||||
|
// and no ability to answer anything, because a person asks.
|
||||||
|
KindPerson Kind = "person"
|
||||||
|
// KindNodeTools is a machine's tool runtime (novox/hq ADR 0175, to-be 38): one process per
|
||||||
|
// node, on the host side, serving every assigned module's tools and every held seat's verbs.
|
||||||
|
// Its authority is the union of what the modules it carries would each have had for their
|
||||||
|
// tools — and nothing of what they consume, because tools are what it runs, not reactions.
|
||||||
|
KindNodeTools Kind = "node-tools"
|
||||||
|
// KindProvisioner is a machine's provisioner client (novox/hq ADR 0349 clauses 6 and 11, design 57 P3): a
|
||||||
|
// service of the module holding NodeProvisionerSeat, outside the tool runner, under an account of its own so
|
||||||
|
// the vault can tell it from an agent. Its authority is provisionerGrants and nothing else.
|
||||||
|
KindProvisioner Kind = "provisioner"
|
||||||
|
// KindProcess is one process of a module that declares bus (novox/hq ADR 0436 §1, §2): a service of that
|
||||||
|
// module, outside the tool runner and outside the module's own account, under a bus account scoped to
|
||||||
|
// exactly what its bus says — never root, never the operator account, and never able to answer a tool
|
||||||
|
// call or a seat's verb (§3). One per process and machine.
|
||||||
|
KindProcess Kind = "process"
|
||||||
|
// KindLedger is one module assignment's ledger credential (novox/hq ADR 0597 §9, design 25 § 12.4): held by that
|
||||||
|
// machine's tool runner, never by the module's own code, and the only account that publishes on that module's ledger
|
||||||
|
// histories and snapshots. One per module assignment, whose module asks for a ledger in `requires`.
|
||||||
|
KindLedger Kind = "ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LedgerHistorySubject and LedgerSnapshotSubject are the subject prefixes of one ledger's history and of its instances'
|
||||||
|
// snapshots (novox/hq ADR 0597 §1, §8, §9): an instance's subject is the prefix, ".", and the instance's path with each
|
||||||
|
// "/" written "." (`decision/0290` is `….decision.0290`). One contract: the grants part grants a ledger account its
|
||||||
|
// publish on them, the tool runner appends and snapshots on them, and the bus module creates the history's stream over
|
||||||
|
// them.
|
||||||
|
func LedgerHistorySubject(module, local string) string {
|
||||||
|
return "mesh.ledger." + module + "." + local + ".history"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LedgerSnapshotSubject is the subject prefix of one ledger's instances' snapshots (LedgerHistorySubject).
|
||||||
|
func LedgerSnapshotSubject(module, local string) string {
|
||||||
|
return "mesh.ledger." + module + "." + local + ".snapshot"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
||||||
|
// emits (novox/hq ADR 0118, design 29 §5).
|
||||||
|
type Seat struct {
|
||||||
|
Name string
|
||||||
|
// Scope is where the seat has one holder. A node-scoped seat's tool carries the node in its
|
||||||
|
// subject, because one subject reaching six machines' holders is not an address
|
||||||
|
// (novox/hq ADR 0132, design 33 §4). Empty reads as mesh.
|
||||||
|
Scope string
|
||||||
|
Accepts []string
|
||||||
|
Emits []string
|
||||||
|
Serves []string
|
||||||
|
Versions []string // protocol versions served beside the current one; empty for v1 only
|
||||||
|
|
||||||
|
// Kinded says the seat is a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): each holder claims one
|
||||||
|
// kind, and its verbs' subjects carry it. Kind is the kind this principal's claim names, for a seat it
|
||||||
|
// holds.
|
||||||
|
Kinded bool
|
||||||
|
Kind string
|
||||||
|
// ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3).
|
||||||
|
ByCaller []string
|
||||||
|
// Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3).
|
||||||
|
Proofs []string
|
||||||
|
// Records are the holder's buckets, by their full name, each user reads under its own name.
|
||||||
|
Records []string
|
||||||
|
// Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2).
|
||||||
|
Capabilities []string
|
||||||
|
// DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind
|
||||||
|
// and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench.
|
||||||
|
DeclaredBy string
|
||||||
|
// PulledByKind says each accepted kind is a worker of its own on the seat's queue, filtered to that kind, which
|
||||||
|
// every holder pulls in the order Accepts lists them: the build seats' (novox/hq ADR 0525 §2, ADR 0536 §4).
|
||||||
|
PulledByKind bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Principal is one user of the bus. Its permissions are derived from what it declares and
|
||||||
|
// nothing else (novox/hq ADR 0043), over the three namespaces of design 29 §2: its own, the seats
|
||||||
|
// it holds, and the seats it uses.
|
||||||
|
type Principal struct {
|
||||||
|
Kind Kind
|
||||||
|
Node string
|
||||||
|
Module string
|
||||||
|
// Process is the process resource's id, for a KindProcess principal alone (novox/hq ADR 0436 §2):
|
||||||
|
// Username and inbox are <node>.<module>.<process id>.
|
||||||
|
Process string
|
||||||
|
|
||||||
|
// FilesFor are the nodes whose declaration-file buckets (novox/hq ADR 0486 §2) this principal may remove, for the bus
|
||||||
|
// module's tools removing the ended controller's leftovers alone (ADR 0590 clause 6): every node of the mesh, one
|
||||||
|
// bucket each.
|
||||||
|
FilesFor []string
|
||||||
|
|
||||||
|
Emits []string
|
||||||
|
Consumes []string
|
||||||
|
Serves []string
|
||||||
|
|
||||||
|
Holds []Seat
|
||||||
|
Uses []Seat
|
||||||
|
// Watches are seats whose events this principal consumes. Separate from Consumes because a
|
||||||
|
// role's event lives under the seat's namespace and not a module's, and this package cannot tell
|
||||||
|
// a seat's name from a module's by looking at it — whoever resolved the declaration can, and
|
||||||
|
// does (novox/hq ADR 0121).
|
||||||
|
//
|
||||||
|
// **Found by a consumer reading nothing.** The catalogue consumes the build machine's outcome;
|
||||||
|
// with that name read as a module's, its subscription pointed at `mesh.mod.mesh-build-machine.…`,
|
||||||
|
// a namespace no such module owns. Every service started and the graph stayed empty.
|
||||||
|
Watches []Seat
|
||||||
|
|
||||||
|
// Carries are the modules whose tools this principal serves, for a KindNodeTools principal
|
||||||
|
// (novox/hq ADR 0175): every module assigned to its node, as each declares itself. Its
|
||||||
|
// serving authority is the union of theirs — each module's own tool namespace and each held
|
||||||
|
// seat's verbs on this node — derived from the same declarations the modules' own principals
|
||||||
|
// are, so the runtime can serve nothing a module could not have served for itself.
|
||||||
|
Carries []Declared
|
||||||
|
|
||||||
|
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
|
||||||
|
// tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so
|
||||||
|
// (novox/hq ADR 0152) — the console's does, and nothing else's.
|
||||||
|
//
|
||||||
|
// **A list, not a role.** A person is not a module and holds no seat: nothing is addressed
|
||||||
|
// to them, nothing is delivered to them, and they have no durable consumer to acknowledge.
|
||||||
|
// What they have is permission to ask. A module that invokes gains exactly the same
|
||||||
|
// permission and nothing beside it.
|
||||||
|
Invokes []string
|
||||||
|
|
||||||
|
// AsksIssued are, for the vault's holder, the tools it asks in its own name for a credential a provider's software
|
||||||
|
// issued, each `<module>.<tool>` (novox/hq ADR 0305, issue 723, vaultCalls): on the subject that names its caller,
|
||||||
|
// so the tool knows the vault asks.
|
||||||
|
AsksIssued []string
|
||||||
|
|
||||||
|
// AsksTheDesk says this principal is the vault's holder, which asks the desks' secret prompt in its own name
|
||||||
|
// (novox/hq ADR 0349 clause 9, issue 723): `node-launcher.secret` on any machine, on the subject that names
|
||||||
|
// its caller, and nothing else of that seat. With AsksIssued on the same principal, the vault's whole reach
|
||||||
|
// outside its own seat: it asks each issuing provider's tool in its own name (clause 10).
|
||||||
|
AsksTheDesk bool
|
||||||
|
|
||||||
|
// State is the local names of the state this principal's module keeps, and Reads the state of
|
||||||
|
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
|
||||||
|
// instances and read by whoever declares it.
|
||||||
|
State []string
|
||||||
|
Reads []string
|
||||||
|
// PerMachine is the local names of its state keyed by machine, reaching the key named for Node and
|
||||||
|
// no other; KeyedReads the keys of others' state it reads one key at a time (novox/hq ADR 0260).
|
||||||
|
PerMachine []string
|
||||||
|
KeyedReads []KeyedRead
|
||||||
|
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
|
||||||
|
Logs []string
|
||||||
|
// ReadLogs are, of Reads, those naming another module's log (novox/hq ADR 0571 clause 1).
|
||||||
|
ReadLogs []string
|
||||||
|
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
|
||||||
|
// subjects it was granted (novox/hq ADR 0363).
|
||||||
|
Streams []ModuleStream
|
||||||
|
Hears []Heard
|
||||||
|
// Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436
|
||||||
|
// §1, §2, §6); for a KindProcess principal, its own alone.
|
||||||
|
Queues []QueueOwner
|
||||||
|
// Asks says a KindProcess principal reads the records of its module's own asks (novox/hq ADR 0436 §1),
|
||||||
|
// on whichever seat of Uses keeps them (novox/hq ADR 0259 §3) — never set for any other kind.
|
||||||
|
Asks bool
|
||||||
|
|
||||||
|
// SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its
|
||||||
|
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
|
||||||
|
// else — not its declarations, which the node's tool runtime serves for it.
|
||||||
|
SnapshotsTheBus bool
|
||||||
|
// Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus
|
||||||
|
// (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them.
|
||||||
|
Removes []string
|
||||||
|
// DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name
|
||||||
|
// (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's.
|
||||||
|
DeadLetterSources []string
|
||||||
|
|
||||||
|
// Checks are the tools a machine principal's node-engine asks as a declared health check, each
|
||||||
|
// `<module>.<tool>` (novox/hq ADR 0240, to-be 48 §3): asked of the instance on its own machine and
|
||||||
|
// nowhere else.
|
||||||
|
Checks []string
|
||||||
|
// MovesIntoTheVault is a machine principal whose node-engine may ask the vault's `move-in` in its own name (design
|
||||||
|
// 57's V3, issue 676): set while the vault's holder serves it, so the engine can hand in the values sealed to its
|
||||||
|
// machine, which nothing else can open.
|
||||||
|
MovesIntoTheVault bool
|
||||||
|
// StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name
|
||||||
|
// (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new.
|
||||||
|
StatesItsKeyToTheVault bool
|
||||||
|
// AsksItsOwnSecrets is a machine principal whose node-engine may ask the vault's `own` in its own name (novox/hq
|
||||||
|
// ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes
|
||||||
|
// itself. Set while the vault's holder serves it.
|
||||||
|
AsksItsOwnSecrets bool
|
||||||
|
// AsksTheVaultToSign is a machine principal whose node-engine may ask the vault's `sign` in its own name, read the
|
||||||
|
// vault's public signing halves (`signers`) and the controller's lease key (novox/hq ADR 0550 §2): its own node's
|
||||||
|
// certificate, the keys it trusts, and the lease write that drops the controller's key. Set while the vault's holder
|
||||||
|
// serves `sign`.
|
||||||
|
AsksTheVaultToSign bool
|
||||||
|
// ServedReads are, for a machine principal, the keys of mesh-config's resolved values that other nodes' modules serve
|
||||||
|
// from (`<node>.<module>.<key>`, catalogue.ServedSettings): what every consumer is told, and so what a node composing
|
||||||
|
// its own modules reads of a provider elsewhere (issue 1126), each key alone. Sorted, none twice.
|
||||||
|
ServedReads []string
|
||||||
|
// FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants
|
||||||
|
// part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows).
|
||||||
|
FollowsGrantsUserList bool
|
||||||
|
// BoundBuckets are the providers' buckets of entries, `<module>_bound`, a provisioner client reads its own
|
||||||
|
// machine's keys of (mesh-sdk go/provider, issue 679): every bucket named bound of a module the mesh places.
|
||||||
|
BoundBuckets []string
|
||||||
|
// CreatesStreams are the streams the grants part's principal alone may create (novox/hq ADR 0547 §5; the operator,
|
||||||
|
// 2026-10-26 08:54): the stream of every bucket, log and stream a manifest of a module placed anywhere declares,
|
||||||
|
// by name. Created only, never updated or deleted: a new module state is on the bus when the grants part hears its
|
||||||
|
// manifest, not at the controller's next start (issue 1790).
|
||||||
|
CreatesStreams []string
|
||||||
|
// CreatesConsumers are the durable consumers the grants part's principal alone may create beside the controller
|
||||||
|
// (novox/hq ADR 0561 §6): each placed module's on EVENTS and each process's on its work queue (AccountConsumers), by
|
||||||
|
// stream and name. Created only, never updated or deleted.
|
||||||
|
CreatesConsumers []ModuleConsumer
|
||||||
|
|
||||||
|
// Login is a node principal's login when it is not `node.<n>`: its second login, `node.<n>--b`, composed beside or
|
||||||
|
// in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted
|
||||||
|
// the node's permissions and the node's inbox `_INBOX.node.<n>`; what it calls, it calls in its own name, so the
|
||||||
|
// vault tells the login that confirms a rotation from the one it replaces (§3).
|
||||||
|
Login string
|
||||||
|
// RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins,
|
||||||
|
// and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight,
|
||||||
|
// §3). Set while the vault's holder serves rotate-node.
|
||||||
|
RotatesItsLogin bool
|
||||||
|
|
||||||
|
// Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else
|
||||||
|
// of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's
|
||||||
|
// enrolment subject from it, which is served no more from the lease write (§6).
|
||||||
|
Redeems bool
|
||||||
|
LeaseHandedAway bool
|
||||||
|
|
||||||
|
// VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but
|
||||||
|
// the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1).
|
||||||
|
VaultEnrols bool
|
||||||
|
|
||||||
|
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||||
|
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||||
|
// secret that can be read from a configuration file is a secret with a wider blast radius
|
||||||
|
// than the one it protects (novox/hq design 29 §10).
|
||||||
|
PasswordHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the
|
||||||
|
// controller may answer an enrolment is derived from the same constant the user is named from.
|
||||||
|
const enrolmentPrefix = "enrol"
|
||||||
|
|
||||||
|
// safeSubject refuses anything that would change the meaning of a subject rather than sit inside
|
||||||
|
// one. A name carrying a dot would silently widen a permission by adding a token; a name carrying
|
||||||
|
// `>` or `*` would widen it to a wildcard, which is the whole authority model gone.
|
||||||
|
var safeSubject = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||||
|
|
||||||
|
// Username is how a principal is named to the server. The node is part of it, so the same module
|
||||||
|
// on two machines holds two users, each sealed to its own — the rule management.go already
|
||||||
|
// applies, kept.
|
||||||
|
func (p Principal) Username() string {
|
||||||
|
switch p.Kind {
|
||||||
|
case KindPerson:
|
||||||
|
return "person." + p.Module
|
||||||
|
case KindModule, KindNodeTools:
|
||||||
|
// The runtime is named exactly as the module it stands for would have been: the mesh
|
||||||
|
// issues its credential through the same path a module's takes (`module issue`), and
|
||||||
|
// that path knows the node and the module, not the kind.
|
||||||
|
return p.Node + "." + p.Module
|
||||||
|
case KindProcess:
|
||||||
|
// One account per process and machine (novox/hq ADR 0436 §2): `<machine>.<module>.<process id>`,
|
||||||
|
// never the module's own name alone, which is a different account with a different secret.
|
||||||
|
return p.Node + "." + p.Module + "." + p.Process
|
||||||
|
case KindNode:
|
||||||
|
if p.Login != "" {
|
||||||
|
return p.Login
|
||||||
|
}
|
||||||
|
return "node." + p.Node
|
||||||
|
case KindLedger:
|
||||||
|
// `ledger.<machine>.<module>`: never `<node>.<module>`, which is the module's own account or the tool runner's,
|
||||||
|
// so the module's code never holds it.
|
||||||
|
return "ledger." + p.Node + "." + p.Module
|
||||||
|
case KindProvisioner:
|
||||||
|
// Named for the machine, as a node is: one client per machine, whichever module runs it, and never
|
||||||
|
// `<node>.<module>`, which is the module's own account or the tool runner's.
|
||||||
|
return "provisioner." + p.Node
|
||||||
|
case KindEnrolment:
|
||||||
|
// Per token, not one shared user. **The inbox is the reason**: with a single `enrolment`
|
||||||
|
// user every machine enrolling at once could read every other's answer, and an answer
|
||||||
|
// carries that node's credentials sealed to it. Design 25 §6 says the inbox a token
|
||||||
|
// derives, and a permission belongs to a user, so the user is per token.
|
||||||
|
//
|
||||||
|
// Named after the node, which **is** the token's id: a token is issued for a node record,
|
||||||
|
// the mesh holds one live claim per record, and the node's name is the one identifier both
|
||||||
|
// sides already have before anything else is agreed. It is also exactly what the other
|
||||||
|
// transport does, where the account is named after the node and the secret is its password.
|
||||||
|
return enrolmentPrefix + "." + p.Node
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||||
|
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||||
|
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||||
|
//
|
||||||
|
// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as.
|
||||||
|
func (p Principal) inbox() string {
|
||||||
|
if p.Kind == KindNode {
|
||||||
|
return "_INBOX.node." + p.Node + ".>"
|
||||||
|
}
|
||||||
|
return "_INBOX." + p.Username() + ".>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseTTL is how long the bus lets a principal answer a request it received. Its one answer has
|
||||||
|
// to come inside this, and a seat's holder answers within link.AnswerWithin — inside it by design.
|
||||||
|
// **A broker reloading its user list forgets every answer it was about to permit**, whatever this
|
||||||
|
// says (novox/hq issue 265): a call that is still running when the list reloads has its answer
|
||||||
|
// refused, which is why a holder answers before it does what can reload it.
|
||||||
|
const ResponseTTL = time.Minute
|
||||||
|
|
||||||
|
// GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants
|
||||||
|
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
|
||||||
|
const GrantsModule = "mesh-grants"
|
||||||
|
|
||||||
|
// The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `<node>.<module>`, its
|
||||||
|
// membership (Membership) bare, as the assignments stream carries it, written only when it differs. A module's runtime
|
||||||
|
// reads its own key, the tool runner its node's; the grants part alone writes it. Both sides go through
|
||||||
|
// membership.EncodeGrantsMembership and membership.DecodeGrantsMembership (or BareGrantsMembership for a reader with a
|
||||||
|
// type of its own), never json of a type of their own: the grants part once wrote its wrapper here and every reader
|
||||||
|
// served nothing (issue 774). The decoder still reads that wrapper, for the transition.
|
||||||
|
const (
|
||||||
|
GrantsMembershipsState = "memberships"
|
||||||
|
GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState
|
||||||
|
)
|
||||||
|
|
||||||
|
// MembershipKey is an assignment's key in the grants part's memberships: `<node>.<module>`.
|
||||||
|
func MembershipKey(node, module string) string { return node + "." + module }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssignmentsStream holds every assignment's membership, the newest per subject.
|
||||||
|
const AssignmentsStream = membership.AssignmentsStream
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is
|
||||||
|
// composed exactly as before them.
|
||||||
|
func (s Seat) isNewTraffic() bool {
|
||||||
|
return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 || s.PulledByKind
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mesh's caps on a bucket, the same for every module: a value is a piece of state, not a file,
|
||||||
|
// and a bucket that grew without bound would be one module filling the bus's disk for everyone.
|
||||||
|
const (
|
||||||
|
StateMaxValueBytes = 256 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Bucket is one module's declared state as the bus holds it.
|
||||||
|
type Bucket struct {
|
||||||
|
Module string
|
||||||
|
Name string
|
||||||
|
// History is how many values a key keeps; zero is one.
|
||||||
|
History int
|
||||||
|
// TTLSeconds is how long a value lives; zero is until replaced or deleted.
|
||||||
|
TTLSeconds int
|
||||||
|
// PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR
|
||||||
|
// 0260), and is granted that key and no other.
|
||||||
|
PerMachine bool
|
||||||
|
// KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its
|
||||||
|
// own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends.
|
||||||
|
KeyTTL bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BucketName is the bucket a module's state lives in: the module and the local name joined by an
|
||||||
|
// underscore, which neither may contain, so two modules can never derive one bucket.
|
||||||
|
func BucketName(module, name string) string { return module + "_" + name }
|
||||||
|
|
||||||
|
// Bucket is this bucket's name on the bus.
|
||||||
|
func (b Bucket) Bucket() string { return BucketName(b.Module, b.Name) }
|
||||||
|
|
||||||
|
// Why is carried into the server's description of the bucket, so somebody reading the server's
|
||||||
|
// own state finds whose it is and why it is kept.
|
||||||
|
func (b Bucket) Why() string {
|
||||||
|
return fmt.Sprintf("%s's state %q (novox/hq ADR 0201): its current value per key, written by %s, "+
|
||||||
|
"read by whatever declares it reads it; kept when %s is unassigned, because it is data",
|
||||||
|
b.Module, b.Name, b.Module, b.Module)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyedRead is one key of another module's state a principal reads, and no other key of it.
|
||||||
|
type KeyedRead struct {
|
||||||
|
Read string `json:"read"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvedBucket is mesh-config's state of resolved values, keyed `<node>.<module>.<key>`.
|
||||||
|
const ResolvedBucket = "mesh-config_resolved"
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Retention is how a stream decides what to keep, which is the whole of what distinguishes the
|
||||||
|
// mesh's four relationships on the wire (design 29 §4).
|
||||||
|
type Retention string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// RetentionWorkQueue: a message is removed once a consumer acknowledges it. Exactly one
|
||||||
|
// worker does the work, and a worker that dies has its message redelivered.
|
||||||
|
RetentionWorkQueue Retention = "workqueue"
|
||||||
|
// RetentionLastPerSubject: only the newest message on each subject survives. This is the
|
||||||
|
// state shape — a node that was away gets exactly the current declaration and nothing older.
|
||||||
|
RetentionLastPerSubject Retention = "last_per_subject"
|
||||||
|
// RetentionLimits: kept until it ages or the stream fills. Events, where a subscriber that
|
||||||
|
// was down catches up and nobody is obliged to act.
|
||||||
|
RetentionLimits Retention = "limits"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Stream is one of the mesh's own, as the controller asserts it.
|
||||||
|
type Stream struct {
|
||||||
|
Name string
|
||||||
|
Subjects []string
|
||||||
|
Retention Retention
|
||||||
|
// MaxAge in seconds, zero for unbounded. Per stream — JetStream has no per-subject age,
|
||||||
|
// which is why differing retention between modules would mean a stream each.
|
||||||
|
MaxAge int
|
||||||
|
// MaxMsgsPerSubject caps each subject independently, so one noisy emitter cannot push
|
||||||
|
// another's events out of a shared stream. Verified: with a cap of 3, ten messages on one
|
||||||
|
// subject and one on another leave four in the stream, not three.
|
||||||
|
MaxMsgsPerSubject int
|
||||||
|
// Why is carried into the assertion so an operator reading the server's own state finds the
|
||||||
|
// reason there, rather than only in a repository they may not have.
|
||||||
|
Why string
|
||||||
|
// Direct lets a client read a subject's last message without a consumer, which is how a
|
||||||
|
// runtime reads its own membership with no JetStream API beyond one request (ADR 0160).
|
||||||
|
Direct bool
|
||||||
|
// MaxBytes bounds the stream's size, zero for unbounded. With DiscardNew a full stream refuses
|
||||||
|
// what comes next rather than dropping what it holds: the publisher is told, and says so.
|
||||||
|
MaxBytes int64
|
||||||
|
DiscardNew bool
|
||||||
|
// DuplicatesSeconds is the window in which a message id published twice is kept once; zero for the
|
||||||
|
// server's default (two minutes).
|
||||||
|
DuplicatesSeconds int
|
||||||
|
// AllowMsgTTL lets a message carry a time to live of its own (`Nats-TTL`), for what must not outlive the
|
||||||
|
// stream it came from (novox/hq ADR 0376 §3, §5).
|
||||||
|
AllowMsgTTL bool
|
||||||
|
// Restricted marks a module's stream restricted by a grant (novox/hq ADR 0363 §4), in the stream's own
|
||||||
|
// metadata, so whoever keeps one of its messages reads it from the bus (ADR 0376 §3).
|
||||||
|
Restricted bool
|
||||||
|
// WorkQueue marks a module stream declared retention: work-queue (novox/hq ADR 0436 §6), in the
|
||||||
|
// stream's own metadata, so a dead letter kept from it is read as drop-only from the bus, by the
|
||||||
|
// declaration and not by the bus's work-queue retention alone — which SEAT_* and other mesh-internal
|
||||||
|
// streams share without being one of ADR 0436's, and whose dead letters are still delivered again
|
||||||
|
// (ADR 0264 §4).
|
||||||
|
WorkQueue bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestrictedMetadata is the key a restricted stream's metadata says it under.
|
||||||
|
const RestrictedMetadata = "mesh-restricted"
|
||||||
|
|
||||||
|
// WorkQueueMetadata is the key a module stream declared retention: work-queue (novox/hq ADR 0436 §6) says
|
||||||
|
// it under: never set by a mesh-internal work-queue stream (a seat's, a check's), only by Stream.WorkQueue.
|
||||||
|
const WorkQueueMetadata = "mesh-work-queue"
|
||||||
|
|
||||||
|
// What a durable consumer gave up on is kept (novox/hq issue 330, design 25 §3).
|
||||||
|
//
|
||||||
|
// **The server says it and keeps it; the controller copies it.** A consumer that handed a message over
|
||||||
|
// as often as it may stops offering it and publishes `$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES` with
|
||||||
|
// the stream and the message's sequence. DeadLetterNoticesStream captures those advisories as the
|
||||||
|
// server publishes them, so one said while no controller listens is still there when one starts. The
|
||||||
|
// controller's consumer on it fetches the given-up message by its sequence, while the source stream
|
||||||
|
// still holds it, and keeps a copy in DeadLettersStream under DeadLetterSubject, with the consumer,
|
||||||
|
// the subject, how often it was handed over and when it was given up. It stays there until a person
|
||||||
|
// delivers it again or drops it, with why; a condition is open for as long as it does.
|
||||||
|
const (
|
||||||
|
DeadLetterNoticesStream = "DEAD_LETTER_NOTICES"
|
||||||
|
DeadLettersStream = "DEAD_LETTERS"
|
||||||
|
// MaxDeliveriesAdvisories is the subject the server says a given-up message on.
|
||||||
|
MaxDeliveriesAdvisories = "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>"
|
||||||
|
deadLetterPrefix = "mesh.events.dead."
|
||||||
|
againPrefix = "mesh.again."
|
||||||
|
// DeadLettersBytes bounds the kept copies. Full, the stream refuses the next copy, which is said
|
||||||
|
// as the consumer's condition; it never drops one it holds.
|
||||||
|
DeadLettersBytes = 256 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeadLetterSubject is where a message one consumer gave up on is kept: `mesh.events.dead.<stream>.<consumer>`.
|
||||||
|
func DeadLetterSubject(stream, consumer string) string {
|
||||||
|
return deadLetterPrefix + stream + "." + consumer
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeadLetterOf is the stream and consumer a kept message's subject names; false for any other subject.
|
||||||
|
func DeadLetterOf(subject string) (stream, consumer string, ok bool) {
|
||||||
|
rest, found := strings.CutPrefix(subject, deadLetterPrefix)
|
||||||
|
if !found {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
stream, consumer, ok = strings.Cut(rest, ".")
|
||||||
|
return stream, consumer, ok && stream != "" && consumer != "" && !strings.Contains(consumer, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeshStreams is the foundation set, in the order a person reads it.
|
||||||
|
//
|
||||||
|
// **EVENTS filters on the `event` token**, which is the reason that token exists. A module's
|
||||||
|
// namespace carries both its events and its tool calls; a filter of `mesh.mod.*.>` would persist
|
||||||
|
// every tool invocation in the mesh, and a tool call must never be persisted (design 25 §3 keeps
|
||||||
|
// tools on core NATS, where a lost call is a timeout the caller already handles).
|
||||||
|
func MeshStreams() []Stream {
|
||||||
|
return []Stream{
|
||||||
|
// CONTROL, the ended controller's work queue of what nodes reported, is no longer the mesh's (novox/hq ADR 0590
|
||||||
|
// clause 6): not declared here, which would make it again; the bus module's tools remove it.
|
||||||
|
{
|
||||||
|
Name: "NODES",
|
||||||
|
Subjects: []string{"mesh.node.*.declare"},
|
||||||
|
Retention: RetentionLastPerSubject,
|
||||||
|
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
|
||||||
|
"n-1 by construction (issue 107)",
|
||||||
|
},
|
||||||
|
// The assignments stream (ADR 0160) is no longer the mesh's: a runtime reads its membership from the grants
|
||||||
|
// part's state memberships (ADR 0571 clause 7), and the bus module's tools delete the stream at the lease write
|
||||||
|
// (research 072 row 33), so it is not declared here, which would make it again.
|
||||||
|
{
|
||||||
|
Name: EventsStream,
|
||||||
|
// A seat's own events ride here too: they are 1:many like any event, and the
|
||||||
|
// `event` token keeps them clear of both the seat's work queue (`accept`) and its
|
||||||
|
// tools (`tool`), which must not be persisted.
|
||||||
|
// And an event given up on, delivered again to the one consumer that gave it up
|
||||||
|
// (novox/hq issue 330): under `mesh.again.<consumer>.`, which only that consumer filters.
|
||||||
|
Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>", againPrefix + ">"},
|
||||||
|
Retention: RetentionLimits,
|
||||||
|
MaxAge: 7 * 24 * 60 * 60,
|
||||||
|
MaxMsgsPerSubject: 10000,
|
||||||
|
// A restricted dead letter delivered again keeps what is left of its stream's age (ADR 0376 §5).
|
||||||
|
AllowMsgTTL: true,
|
||||||
|
Why: "a subscriber that was down catches up; tool traffic under the same prefix is " +
|
||||||
|
"excluded by the event token; per-subject caps keep a noisy emitter from " +
|
||||||
|
"evicting a quiet one without splitting the stream",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: DeadLetterNoticesStream,
|
||||||
|
Subjects: []string{MaxDeliveriesAdvisories},
|
||||||
|
Retention: RetentionWorkQueue,
|
||||||
|
MaxAge: 7 * 24 * 60 * 60,
|
||||||
|
Why: "the server's word that a consumer gave up on a message, kept until the controller has " +
|
||||||
|
"copied the message into DEAD_LETTERS (novox/hq issue 330); a week, the longest the source " +
|
||||||
|
"streams keep what they are about",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: DeadLettersStream,
|
||||||
|
Subjects: []string{deadLetterPrefix + ">"},
|
||||||
|
Retention: RetentionLimits,
|
||||||
|
MaxBytes: DeadLettersBytes,
|
||||||
|
DiscardNew: true,
|
||||||
|
DuplicatesSeconds: 24 * 60 * 60,
|
||||||
|
AllowMsgTTL: true,
|
||||||
|
Why: "every message a consumer gave up on, with its consumer, subject, deliveries and when, kept " +
|
||||||
|
"until a person delivers it again or drops it with why (novox/hq issue 330); no age but a restricted " +
|
||||||
|
"stream's message, which ages out with its stream (ADR 0376), and full it refuses the next copy " +
|
||||||
|
"rather than drop one it holds",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: ProvAppliedStream,
|
||||||
|
Subjects: []string{ProvAppliedSubjects},
|
||||||
|
Retention: RetentionLastPerSubject,
|
||||||
|
Direct: true,
|
||||||
|
MaxAge: 7 * 24 * 60 * 60,
|
||||||
|
Why: "each machine's provisioner client's newest word on each module's files, applied, waiting or " +
|
||||||
|
"failed with the reason (novox/hq ADR 0349 §4): what a walk's end and a gate wait for (ADR 0362, " +
|
||||||
|
"ADR 0349 §15), read by subject; a week, as the events are",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvAppliedStream keeps what each machine's provisioner client last said of each module's files, newest per
|
||||||
|
// subject (novox/hq ADR 0349 §4, ADR 0362, design 57 P6): `prov.applied.<node>.<module>` lies outside `mesh.*`, so
|
||||||
|
// no other stream captures it, and a report said while no controller listened is still there to be read.
|
||||||
|
const ProvAppliedStream = "PROV_APPLIED"
|
||||||
|
|
||||||
|
// ProvAppliedSubjects is every machine's client's reports: `prov.applied.<node>.<module>`, a module's name dots and
|
||||||
|
// all (ProvisionerAppliedSubject).
|
||||||
|
const ProvAppliedSubjects = "prov.applied.>"
|
||||||
|
|
||||||
|
// The ended controller's consumers' names (novox/hq ADR 0590 clause 6): kept only so its leftovers are named for their
|
||||||
|
// removal (ControllerLeftoverConsumers) and the watchman can read whether `controller` on DEAD_LETTER_NOTICES still
|
||||||
|
// stands.
|
||||||
|
const (
|
||||||
|
ControllerName = "controller"
|
||||||
|
MergesConsumerName = "controller-merges"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventsStream holds every module's and every role's events, a build's log among them.
|
||||||
|
const EventsStream = "EVENTS"
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
|
||||||
|
// Declared is one module on one node, as composing its authority needs it.
|
||||||
|
type Declared struct {
|
||||||
|
Module string
|
||||||
|
Emits []string
|
||||||
|
Consumes []string
|
||||||
|
Serves []string
|
||||||
|
// Served are the config keys its served values and zone name fill from its settings (catalogue.ServedSettings): what
|
||||||
|
// every node may read of its resolved values where it runs (Principal.ServedReads, issue 1126).
|
||||||
|
Served []string
|
||||||
|
// Holds are the seats this module claims, with the protocol each seat declares. A seat the
|
||||||
|
// mesh defines for itself declares no protocol, so holding one grants nothing on the bus —
|
||||||
|
// which is right: those seats are about who does a job, not about who may say what.
|
||||||
|
Holds []Seat
|
||||||
|
// Uses are the seats this module sends to.
|
||||||
|
Uses []Seat
|
||||||
|
// Issued are the tools that answer a credential its software issued, one per offer that says so (novox/hq
|
||||||
|
// ADR 0305): the controller is granted to ask each in its own name, and no other tool so.
|
||||||
|
Issued []string
|
||||||
|
// Watches are the seats whose events it consumes.
|
||||||
|
Watches []Seat
|
||||||
|
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||||
|
Invokes []string
|
||||||
|
// Waits are the event families a session may wait on through the tool runner, the tool runner's module's
|
||||||
|
// alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer.
|
||||||
|
Waits []Wait
|
||||||
|
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
|
||||||
|
State []Bucket
|
||||||
|
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
|
||||||
|
Logs []Log
|
||||||
|
// Streams are the streams it owns, each its events' one place (novox/hq ADR 0363).
|
||||||
|
Streams []ModuleStream
|
||||||
|
// Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436
|
||||||
|
// §1, §2, §6): one durable consumer per entry, named `<node>_<module>_<process id>`.
|
||||||
|
Queues []QueueOwner
|
||||||
|
// Hears are the module streams it hears on this machine, each with the subjects it was granted
|
||||||
|
// (novox/hq ADR 0363 §5); HearsAStream says its `consumes` names a module that owns a stream, granted
|
||||||
|
// or not, so its membership is issued again when a grant changes.
|
||||||
|
Hears []Heard
|
||||||
|
HearsAStream bool
|
||||||
|
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
||||||
|
Reads []string
|
||||||
|
// ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR
|
||||||
|
// 0495 clause 3, issue 1790): granted nothing, kept to be said.
|
||||||
|
ReadsRefused []string
|
||||||
|
// ReadLogs are, of its reads, those naming another module's log (`<module>.<log>`, novox/hq ADR 0571 clause 1): filled
|
||||||
|
// by Users and PlacementsOf from the logs the modules placed anywhere declare, never by a caller.
|
||||||
|
ReadLogs []string
|
||||||
|
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||||
|
// granted for the pieces the modules beside it offer (novox/hq ADR 0260).
|
||||||
|
KeyedReads []KeyedRead
|
||||||
|
// NoAccount says the module declares no own secret named broker, so no account could ever be
|
||||||
|
// delivered to it and nothing can connect as it (novox/hq issue 195). Said in the negative so a
|
||||||
|
// record that does not say is composed as it always was.
|
||||||
|
NoAccount bool
|
||||||
|
// SnapshotsTheBus says the module holds mesh-broker — it is the bus — and so is the one module
|
||||||
|
// granted the snapshot API, to copy the bus's streams for the night's backup (novox/hq ADR 0235).
|
||||||
|
SnapshotsTheBus bool
|
||||||
|
// Checks are the module's own tools its health asks, each `<module>.<tool>` (novox/hq ADR 0240, to-be
|
||||||
|
// 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more.
|
||||||
|
Checks []string
|
||||||
|
// Loads says the module has a compiled bundle whose entrypoints the machine's tool runtime loads and runs
|
||||||
|
// (novox/hq ADR 0175; issue 1329): a helper that reaches the bus on the runner's account, so the runner
|
||||||
|
// must carry the module although it declares no tools.
|
||||||
|
Loads bool
|
||||||
|
// RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never
|
||||||
|
// carried by the machine's runtime, and reaches the bus on its own account.
|
||||||
|
RunsAs string
|
||||||
|
// ProvisionsNode says the module runs the machine's provisioner client: it claims NodeProvisionerSeat and
|
||||||
|
// declares the own secret ProvisionerSecret its account is delivered in (novox/hq ADR 0349 clause 11, design 57
|
||||||
|
// P3). The client is given a bus account of its own, never the module's or the tool runner's.
|
||||||
|
ProvisionsNode bool
|
||||||
|
// Processes are this module's own processes that declare bus (novox/hq ADR 0436 §1), each getting a
|
||||||
|
// principal of its own: granted exactly what its bus says, and nothing of this module's other grants —
|
||||||
|
// composed whether or not the module itself has an account (NoAccount), because a process account is
|
||||||
|
// delivered in its own secret, broker-<process id>, never the module's `broker`.
|
||||||
|
Processes []ProcessPrincipal
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessPrincipal is one process of a module that declares bus (novox/hq ADR 0436 §1, §2), as the broker
|
||||||
|
// composes its own account from: its own emits, its own work-queue consumers, its own state, whether it
|
||||||
|
// reads the router's records of its module's own asks, and the tools and seat verbs it calls.
|
||||||
|
type ProcessPrincipal struct {
|
||||||
|
// Process is the process resource's id, naming the account <node>.<module>.<process id>.
|
||||||
|
Process string
|
||||||
|
// Emits are the events this process may publish, named as the module's own Emits are.
|
||||||
|
Emits []string
|
||||||
|
// Queues are the module's own work-queue streams this process is the one consumer of.
|
||||||
|
Queues []QueueOwner
|
||||||
|
// Removes are what its account may remove from the bus, as its bus declares them (the indexer's judge refuses any other; novox/hq
|
||||||
|
// ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants.
|
||||||
|
Removes []string
|
||||||
|
// State is the module's own state, by local name, this process reads and writes.
|
||||||
|
State []string
|
||||||
|
// Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on
|
||||||
|
// whichever seats the module uses that keep them (the router's, operator-channel, among others).
|
||||||
|
Asks bool
|
||||||
|
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
|
||||||
|
// seat:<seat>.<verb>, or * for every tool on the mesh.
|
||||||
|
Invokes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
||||||
|
const VaultSeat = catalogue.VaultSeat
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the
|
||||||
|
// emitter — a seat or a module — and the event, as the inventory resolved them.
|
||||||
|
type Wait struct {
|
||||||
|
// Name is the family as the manifest names it, `<emitter>.<event>`: what a session asks for.
|
||||||
|
Name string
|
||||||
|
Emitter string
|
||||||
|
// Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121).
|
||||||
|
Seat bool
|
||||||
|
// Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a
|
||||||
|
// seat that names it last (ADR 0259 §3).
|
||||||
|
Event string
|
||||||
|
// Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming
|
||||||
|
// its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited
|
||||||
|
// on with it given, and refused without.
|
||||||
|
Last string
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Package busretry waits out a bus that answers nothing for a few seconds (issue 774, the sweep of 2026-10-30: bus
|
||||||
|
// blackouts at every user-list reload). nats-server 2.11's reloadAuthorization rebuilds the accounts, and for a few
|
||||||
|
// seconds the mesh's account has none of its imports, $JS.API.> among them: every request is answered "no responders"
|
||||||
|
// and every JetStream call "jetstream not enabled". A write or a call made in that window failed, and was lost.
|
||||||
|
//
|
||||||
|
// Only an error that says the request reached no one is waited out (Undelivered): nothing was done, so asking again
|
||||||
|
// is safe even for a write that is not idempotent. A timeout, or any answer, is never asked again: the request may
|
||||||
|
// have been delivered. The wait is bounded (Bound, about 30 s, backing off), and said on standard error when it ends
|
||||||
|
// without an answer.
|
||||||
|
//
|
||||||
|
// This is the quick fix. The thorough one is a reload that keeps the account's imports, in the bus's holder.
|
||||||
|
package busretry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bound is how long a request that reached no one is asked again; FirstWait the first pause, doubled to MostWait.
|
||||||
|
var (
|
||||||
|
Bound = 30 * time.Second
|
||||||
|
FirstWait = 250 * time.Millisecond
|
||||||
|
MostWait = 4 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Waits sets Bound, FirstWait and MostWait and returns what puts the earlier values back. They are state every caller
|
||||||
|
// in a process shares, so a test sets them only through it, as t.Cleanup(busretry.Waits(...)): a test that set them
|
||||||
|
// and left them set shortened the next test's bound to milliseconds, and a loaded check machine overran it (the check
|
||||||
|
// of release sdk1, 2026-11-02). A bound a test waits on to succeed is seconds: no slow machine reaches it.
|
||||||
|
func Waits(bound, first, most time.Duration) (restore func()) {
|
||||||
|
was := [3]time.Duration{Bound, FirstWait, MostWait}
|
||||||
|
Bound, FirstWait, MostWait = bound, first, most
|
||||||
|
return func() { Bound, FirstWait, MostWait = was[0], was[1], was[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undelivered says err means the request reached no one: no responders, JetStream not enabled (for the account), or
|
||||||
|
// no response from the stream after the client's own retries on no responders; also said in words by a runtime that
|
||||||
|
// passed the error on.
|
||||||
|
func Undelivered(err error) bool {
|
||||||
|
var answered *Answered
|
||||||
|
if err == nil || errors.As(err, &answered) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, jetstream.ErrJetStreamNotEnabled) ||
|
||||||
|
errors.Is(err, jetstream.ErrJetStreamNotEnabledForAccount) || errors.Is(err, jetstream.ErrNoStreamResponse) ||
|
||||||
|
errors.Is(err, nats.ErrJetStreamNotEnabled) || errors.Is(err, nats.ErrJetStreamNotEnabledForAccount) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
words := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(words, "no responders") || strings.Contains(words, "jetstream not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answered is an error a callee answered with: the request was delivered, whatever its words say ("jetstream not
|
||||||
|
// enabled" in a callee's own answer), so Undelivered never counts it and Do never asks it again.
|
||||||
|
type Answered struct{ Words string }
|
||||||
|
|
||||||
|
func (a *Answered) Error() string { return a.Words }
|
||||||
|
|
||||||
|
// Do runs try, and again while its error says the request reached no one, until Bound has passed. blackout, when
|
||||||
|
// given, is asked after each such error and Do stops waiting when it says the bus is not blacked out: a call nobody
|
||||||
|
// answers because its callee is not running fails at once, as before. what names the request in the error.
|
||||||
|
func Do[T any](what string, try func() (T, error), blackout func() bool) (T, error) {
|
||||||
|
v, err := try()
|
||||||
|
if !Undelivered(err) || (blackout != nil && !blackout()) {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
start, wait := time.Now(), FirstWait
|
||||||
|
for time.Since(start) < Bound {
|
||||||
|
time.Sleep(wait)
|
||||||
|
wait = min(wait*2, MostWait)
|
||||||
|
v, err = try()
|
||||||
|
if !Undelivered(err) || (blackout != nil && !blackout()) {
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = fmt.Errorf("%s: the bus answered nothing for %s, asked again until then (a user-list reload drops the "+
|
||||||
|
"account's imports for a few seconds; this was longer): %w", what, time.Since(start).Round(time.Second), err)
|
||||||
|
fmt.Fprintf(os.Stderr, "[mesh-sdk] %v\n", err)
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package busretry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issue 774 (bus blackouts at every user-list reload): only a request that reached no one is asked again, within the
|
||||||
|
// bound; a blackout probe that says the bus answers ends the wait at once.
|
||||||
|
func TestABusThatAnswersNothingIsWaitedOut(t *testing.T) {
|
||||||
|
for _, row := range []struct {
|
||||||
|
name string
|
||||||
|
errs []error // what each try answers; the last repeats
|
||||||
|
blackout func() bool
|
||||||
|
tries int // at least
|
||||||
|
atMost int
|
||||||
|
delivered bool
|
||||||
|
}{
|
||||||
|
{"no responders, then an answer", []error{nats.ErrNoResponders, nats.ErrNoResponders, nil}, nil, 3, 3, true},
|
||||||
|
{"jetstream not enabled, then an answer", []error{jetstream.ErrJetStreamNotEnabled, nil}, nil, 2, 2, true},
|
||||||
|
{"said in words by a runtime", []error{errors.New("nats: no responders available for request"), nil}, nil, 2, 2, true},
|
||||||
|
{"a timeout may have been delivered: never asked again", []error{context.DeadlineExceeded}, nil, 1, 1, false},
|
||||||
|
{"an answer that refuses is an answer", []error{errors.New("refused")}, nil, 1, 1, false},
|
||||||
|
{"nobody answers on a bus that answers: the callee is not running", []error{nats.ErrNoResponders}, func() bool { return false }, 1, 1, false},
|
||||||
|
{"blacked out past the bound: said", []error{jetstream.ErrJetStreamNotEnabledForAccount}, func() bool { return true }, 2, 1000, false},
|
||||||
|
} {
|
||||||
|
// A row that ends in an answer waits on a bound of seconds, which no slow machine reaches; only the row that
|
||||||
|
// runs past it has a short one, and asks only that it was asked again at least once.
|
||||||
|
bound := 10 * time.Second
|
||||||
|
if strings.HasPrefix(row.name, "blacked out") {
|
||||||
|
bound = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
t.Cleanup(Waits(bound, time.Millisecond, 4*time.Millisecond))
|
||||||
|
tries := 0
|
||||||
|
_, err := Do("the test", func() (int, error) {
|
||||||
|
e := row.errs[min(tries, len(row.errs)-1)]
|
||||||
|
tries++
|
||||||
|
return tries, e
|
||||||
|
}, row.blackout)
|
||||||
|
if tries < row.tries || tries > row.atMost || (err == nil) != row.delivered {
|
||||||
|
t.Errorf("%s: %d tries, %v", row.name, tries, err)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(row.name, "blacked out") && (!Undelivered(err) || !strings.Contains(fmt.Sprint(err), "the bus answered nothing")) {
|
||||||
|
t.Errorf("%s: %v", row.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package stdio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// busStateCalls are the method prefixes of the calls a bundle makes on its own bus state through the runtime (novox/hq
|
||||||
|
// ADR 0595 §9, design 25 § 12.5): their shapes and names are package buscalls's, and package busstate the typed way
|
||||||
|
// to make them.
|
||||||
|
var busStateCalls = []string{"mesh/objects.", "mesh/stream.", "mesh/workqueue.", "mesh/ledger."}
|
||||||
|
|
||||||
|
// BusState asks the runtime one call on this bundle's own bus state, an entry its module asks for in `requires`, named
|
||||||
|
// by its local name in params; the runtime serves it from the module's own bindings only. A method that is none of
|
||||||
|
// those calls is refused here, before the runtime is asked.
|
||||||
|
func BusState(method string, params any) (json.RawMessage, error) {
|
||||||
|
known := false
|
||||||
|
for _, p := range busStateCalls {
|
||||||
|
known = known || strings.HasPrefix(method, p)
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
return nil, fmt.Errorf("%s is no call on a bundle's own bus state (%s)", method, strings.Join(busStateCalls, ", "))
|
||||||
|
}
|
||||||
|
c, err := live()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.ask(method, params, 60*time.Second)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Package busstate is how a bundle uses its own bus state through the runtime (novox/hq ADR 0595 §9, design 25 §
|
||||||
|
// 12.5): the object store's put, get, list, delete and fill, each naming an entry by the local name its module asks for
|
||||||
|
// it by in `requires`. The runtime serves the calls from the module's own bindings, its own entries only. The shapes
|
||||||
|
// are package buscalls's; this package makes the calls.
|
||||||
|
package busstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Objects is one object store the module asks for in `requires` (the template objects, ADR 0595 §2), by its local name.
|
||||||
|
type Objects struct{ local string }
|
||||||
|
|
||||||
|
// ObjectsOf is the object store of that local name.
|
||||||
|
func ObjectsOf(local string) Objects { return Objects{local: local} }
|
||||||
|
|
||||||
|
func call[T any](method string, params any) (T, error) {
|
||||||
|
var out T
|
||||||
|
raw, err := stdio.BusState(method, params)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return out, fmt.Errorf("the runtime's answer to %s: %w", method, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put stores one object whole under its name, replacing one of that name, and answers it as the store holds it.
|
||||||
|
func (o Objects) Put(name string, data []byte, description string) (buscalls.ObjectInfo, error) {
|
||||||
|
return call[buscalls.ObjectInfo](buscalls.ObjectsPut, buscalls.ObjectsPutRequest{Objects: o.local, Name: name,
|
||||||
|
Data: data, Description: description})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get is one object, its info and its bytes.
|
||||||
|
func (o Objects) Get(name string) (buscalls.ObjectData, error) {
|
||||||
|
return call[buscalls.ObjectData](buscalls.ObjectsGet, buscalls.ObjectsNameRequest{Objects: o.local, Name: name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// List is every object the store holds.
|
||||||
|
func (o Objects) List() ([]buscalls.ObjectInfo, error) {
|
||||||
|
l, err := call[buscalls.ObjectsListed](buscalls.ObjectsList, buscalls.ObjectsRequest{Objects: o.local})
|
||||||
|
return l.Objects, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes one object.
|
||||||
|
func (o Objects) Delete(name string) error {
|
||||||
|
_, err := call[buscalls.Done](buscalls.ObjectsDelete, buscalls.ObjectsNameRequest{Objects: o.local, Name: name})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill is how much the store holds against its cap.
|
||||||
|
func (o Objects) Fill() (buscalls.Fill, error) {
|
||||||
|
return call[buscalls.Fill](buscalls.ObjectsInfo, buscalls.ObjectsRequest{Objects: o.local})
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package busstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A bundle keeps objects in its own object store through the runtime (ADR 0595 §2, §9; A3's request for distribution's
|
||||||
|
// incoming): each call is the runtime's method of buscalls, naming the store by its local name, and answers as the
|
||||||
|
// runtime answered; a method that is no bus-state call is refused before the runtime is asked.
|
||||||
|
func TestABundleKeepsObjectsThroughTheRuntime(t *testing.T) {
|
||||||
|
t.Setenv(stdio.ServedAs, "distribution")
|
||||||
|
inR, inW := io.Pipe()
|
||||||
|
outR, outW := io.Pipe()
|
||||||
|
type result struct {
|
||||||
|
what string
|
||||||
|
err error
|
||||||
|
got any
|
||||||
|
}
|
||||||
|
results := make(chan result, 8)
|
||||||
|
go func() {
|
||||||
|
_ = stdio.ServeOn("", []stdio.Tool{{Name: "keep", Run: func(map[string]any) (any, error) {
|
||||||
|
go func() {
|
||||||
|
in := ObjectsOf("incoming")
|
||||||
|
info, err := in.Put("t1.part0", []byte("hello"), "a part")
|
||||||
|
results <- result{"put", err, info.Digest}
|
||||||
|
data, err := in.Get("t1.part0")
|
||||||
|
results <- result{"get", err, string(data.Data)}
|
||||||
|
list, err := in.List()
|
||||||
|
results <- result{"list", err, len(list)}
|
||||||
|
results <- result{"delete", in.Delete("t1.part0"), nil}
|
||||||
|
fill, err := in.Fill()
|
||||||
|
results <- result{"fill", err, fill.MaxBytes}
|
||||||
|
_, err = stdio.BusState("mesh/state.get", nil)
|
||||||
|
results <- result{"refused", err, nil}
|
||||||
|
}()
|
||||||
|
return "started", nil
|
||||||
|
}}}, inR, outW)
|
||||||
|
}()
|
||||||
|
lines := bufio.NewScanner(outR)
|
||||||
|
next := func() map[string]any {
|
||||||
|
done := make(chan map[string]any, 1)
|
||||||
|
go func() {
|
||||||
|
if lines.Scan() {
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(lines.Bytes(), &m)
|
||||||
|
done <- m
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case m := <-done:
|
||||||
|
return m
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("the bundle said nothing")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send := func(m map[string]any) { b, _ := json.Marshal(m); _, _ = inW.Write(append(b, '\n')) }
|
||||||
|
send(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "keep"}})
|
||||||
|
answers := map[string]any{
|
||||||
|
buscalls.ObjectsPut: buscalls.ObjectInfo{Name: "t1.part0", Size: 5, Digest: "SHA-256=x"},
|
||||||
|
buscalls.ObjectsGet: buscalls.ObjectData{Info: buscalls.ObjectInfo{Name: "t1.part0"}, Data: []byte("hello")},
|
||||||
|
buscalls.ObjectsList: buscalls.ObjectsListed{Objects: []buscalls.ObjectInfo{{Name: "t1.part0"}}},
|
||||||
|
buscalls.ObjectsDelete: buscalls.Done{},
|
||||||
|
buscalls.ObjectsInfo: buscalls.Fill{Count: 0, MaxBytes: 20 << 30},
|
||||||
|
}
|
||||||
|
asked := map[string]map[string]any{}
|
||||||
|
for len(asked) < len(answers) {
|
||||||
|
m := next()
|
||||||
|
method, _ := m["method"].(string)
|
||||||
|
if method == "" {
|
||||||
|
continue // the tool's own answer
|
||||||
|
}
|
||||||
|
p, _ := m["params"].(map[string]any)
|
||||||
|
asked[method] = p
|
||||||
|
send(map[string]any{"jsonrpc": "2.0", "id": m["id"], "result": answers[method]})
|
||||||
|
}
|
||||||
|
want := map[string]any{"put": "SHA-256=x", "get": "hello", "list": 1, "delete": nil, "fill": uint64(20 << 30)}
|
||||||
|
for range 5 {
|
||||||
|
r := <-results
|
||||||
|
if r.err != nil || r.got != want[r.what] {
|
||||||
|
t.Errorf("%s answered %v, %v; want %v", r.what, r.got, r.err, want[r.what])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r := <-results; r.err == nil {
|
||||||
|
t.Error("a call that is not on the bundle's bus state was asked of the runtime")
|
||||||
|
}
|
||||||
|
for method, p := range asked {
|
||||||
|
if p["objects"] != "incoming" {
|
||||||
|
t.Errorf("%s named the store %v, not its local name", method, p["objects"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p := asked[buscalls.ObjectsPut]; p["name"] != "t1.part0" || p["data"] != "aGVsbG8=" || p["description"] != "a part" {
|
||||||
|
t.Errorf("put asked %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Package calladdress reads the key a module calls a tool or a seat verb by, as a module's invokes names it, with the
|
||||||
|
// machine it is asked of after '@': `seat:<seat>.<verb>[@<node>]` or `<module>.<tool>[@<node>]` (novox/hq ADR 0159,
|
||||||
|
// ADR 0469 §3). Every caller in the SDK parses its key here before it publishes anything, so a malformed key fails at
|
||||||
|
// once and says why, never as a call nobody serves and a timeout (novox/hq issue 774, the lease write's stop asked on
|
||||||
|
// `...call.stop@novox-be...`).
|
||||||
|
package calladdress
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Address is a parsed key: a seat and its verb, or a module and its tool, and the machine named, if any.
|
||||||
|
type Address struct {
|
||||||
|
Seat, Verb string
|
||||||
|
Module, Tool string
|
||||||
|
Node string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key is the address as a module's invokes names it, without the machine.
|
||||||
|
func (a Address) Key() string {
|
||||||
|
if a.Seat != "" {
|
||||||
|
return "seat:" + a.Seat + "." + a.Verb
|
||||||
|
}
|
||||||
|
return a.Module + "." + a.Tool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse reads a key, refusing one with more than one '@', a node that is not one subject token, or a seat, verb, module
|
||||||
|
// or tool that holds a wildcard, white space or (but a tool) a dot.
|
||||||
|
func Parse(key string) (Address, error) {
|
||||||
|
name, node, err := cutNode(key)
|
||||||
|
if err != nil {
|
||||||
|
return Address{}, err
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(name, "seat:"); ok {
|
||||||
|
seat, verb, ok := strings.Cut(rest, ".")
|
||||||
|
if !ok || seat == "" || verb == "" {
|
||||||
|
return Address{}, fmt.Errorf("%q names no seat and verb (seat:<seat>.<verb>)", key)
|
||||||
|
}
|
||||||
|
if err := tokens(key, seat, verb); err != nil {
|
||||||
|
return Address{}, err
|
||||||
|
}
|
||||||
|
return Address{Seat: seat, Verb: verb, Node: node}, nil
|
||||||
|
}
|
||||||
|
module, tool, ok := strings.Cut(name, ".")
|
||||||
|
if !ok || module == "" || tool == "" {
|
||||||
|
return Address{}, fmt.Errorf("%q is not <module>.<tool>, and does not start seat:<seat>.<verb>", key)
|
||||||
|
}
|
||||||
|
if err := tokens(key, module); err != nil {
|
||||||
|
return Address{}, err
|
||||||
|
}
|
||||||
|
if err := checkTool(key, tool); err != nil {
|
||||||
|
return Address{}, err
|
||||||
|
}
|
||||||
|
return Address{Module: module, Tool: tool, Node: node}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckOwn is Parse for a caller that may also name a tool of its own module by its bare name (the runtime's mesh/ask).
|
||||||
|
func CheckOwn(key string) error {
|
||||||
|
name, _, err := cutNode(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(name, "seat:") && !strings.Contains(name, ".") {
|
||||||
|
return tokens(key, name)
|
||||||
|
}
|
||||||
|
_, err = Parse(key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckNode refuses a node name that is not one subject token.
|
||||||
|
func CheckNode(node string) error {
|
||||||
|
if node == "" || strings.ContainsAny(node, ".*>@ \t\r\n") {
|
||||||
|
return fmt.Errorf("%q is not a node name (one subject token: no '.', '*', '>', '@' or space)", node)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cutNode(key string) (name, node string, err error) {
|
||||||
|
name, node, at := strings.Cut(key, "@")
|
||||||
|
if !at {
|
||||||
|
return key, "", nil
|
||||||
|
}
|
||||||
|
if strings.Contains(node, "@") {
|
||||||
|
return "", "", fmt.Errorf("%q holds more than one '@': a key names at most one machine", key)
|
||||||
|
}
|
||||||
|
if err := CheckNode(node); err != nil {
|
||||||
|
return "", "", fmt.Errorf("%q: %w", key, err)
|
||||||
|
}
|
||||||
|
return name, node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokens(key string, parts ...string) error {
|
||||||
|
for _, p := range parts {
|
||||||
|
if p == "" || strings.ContainsAny(p, ".*> \t\r\n") {
|
||||||
|
return fmt.Errorf("%q: %q is not one subject token (no '.', '*', '>' or space)", key, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkTool refuses a tool name with a wildcard or white space; a dot is kept, as the tool runner serves it.
|
||||||
|
func checkTool(key, tool string) error {
|
||||||
|
if strings.ContainsAny(tool, "*> \t\r\n") || strings.HasPrefix(tool, ".") || strings.HasSuffix(tool, ".") {
|
||||||
|
return fmt.Errorf("%q: %q is not one subject token (no '*', '>' or space)", key, tool)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package calladdress
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Issue 774 notes 84-87 (the lease write's stop asked of `seat:node-service-manager.stop@novox-be` published on a subject
|
||||||
|
// nobody serves and timed out after 30 s): a key names its machine after '@', and every other malformed key is refused
|
||||||
|
// before anything is published, naming why.
|
||||||
|
func TestAKeyIsParsedOrRefusedBeforeItIsPublished(t *testing.T) {
|
||||||
|
for _, row := range []struct {
|
||||||
|
key, want string // want: "seat|<seat>|<verb>|<node>", "mod|<module>|<tool>|<node>", or a refusal's words
|
||||||
|
}{
|
||||||
|
{"seat:node-service-manager.stop@novox", "seat|node-service-manager|stop|novox"},
|
||||||
|
{"seat:operator-channel.tell", "seat|operator-channel|tell|"},
|
||||||
|
{"mesh-build-go.fetch@ace", "mod|mesh-build-go|fetch|ace"},
|
||||||
|
{"nats.nats_users", "mod|nats|nats_users|"},
|
||||||
|
{"seat:node-service-manager.stop@novox@g14", "more than one '@'"},
|
||||||
|
{"seat:node-service-manager.stop@", "not a node name"},
|
||||||
|
{"seat:node-service-manager.stop@nov.ox", "not a node name"},
|
||||||
|
{"seat:node-files.*", "not one subject token"},
|
||||||
|
{"mesh-build-go.fe tch", "not one subject token"},
|
||||||
|
{"mesh>build.fetch", "not one subject token"},
|
||||||
|
{"seat:noverb", "names no seat and verb"},
|
||||||
|
{"nodot", "is not <module>.<tool>"},
|
||||||
|
{"", "is not <module>.<tool>"},
|
||||||
|
} {
|
||||||
|
a, err := Parse(row.key)
|
||||||
|
got := ""
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
got = err.Error()
|
||||||
|
if !strings.Contains(got, row.want) {
|
||||||
|
t.Errorf("Parse(%q) refused %q, want %q", row.key, got, row.want)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case a.Seat != "":
|
||||||
|
got = "seat|" + a.Seat + "|" + a.Verb + "|" + a.Node
|
||||||
|
default:
|
||||||
|
got = "mod|" + a.Module + "|" + a.Tool + "|" + a.Node
|
||||||
|
}
|
||||||
|
if got != row.want {
|
||||||
|
t.Errorf("Parse(%q) = %s, want %s", row.key, got, row.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a, err := Parse("seat:node-files.read@g14"); err != nil || a.Key() != "seat:node-files.read" {
|
||||||
|
t.Errorf("the key without its machine: %q, %v", a.Key(), err)
|
||||||
|
}
|
||||||
|
if err := CheckOwn("list@ace"); err != nil {
|
||||||
|
t.Errorf("a module's own tool by its bare name was refused: %v", err)
|
||||||
|
}
|
||||||
|
if err := CheckOwn("li*st"); err == nil {
|
||||||
|
t.Error("a bare name that is not one token was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-6
@@ -22,8 +22,19 @@ const MetaCaller = "mesh/caller"
|
|||||||
// parts are safe in a subject. Anything else the runtime named is not a caller and reads as Unproven.
|
// parts are safe in a subject. Anything else the runtime named is not a caller and reads as Unproven.
|
||||||
var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
|
var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
|
||||||
|
|
||||||
// calls holds the caller of every call while its tool runs, by the arguments map that call was handed.
|
// MetaCall is where in a `tools/call`'s `_meta` the runtime names the id of the call in flight, the id a ledger's
|
||||||
var calls sync.Map // unsafe.Pointer → string
|
// apply names so the runtime stamps the caller it proved as the operation's author (novox/hq ADR 0597 §7):
|
||||||
|
// buscalls.MetaCall, written here too so this package imports nothing of buscalls.
|
||||||
|
const MetaCall = "mesh/call"
|
||||||
|
|
||||||
|
// callID is the shape of a call's id: one subject-safe token, as the runtime makes it. Anything else reads as none.
|
||||||
|
var callID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
||||||
|
|
||||||
|
// inFlight is what the runtime said of one call while its tool runs.
|
||||||
|
type inFlight struct{ caller, call string }
|
||||||
|
|
||||||
|
// calls holds the caller and the id of every call while its tool runs, by the arguments map that call was handed.
|
||||||
|
var calls sync.Map // unsafe.Pointer → inFlight
|
||||||
|
|
||||||
func argsKey(args map[string]any) unsafe.Pointer {
|
func argsKey(args map[string]any) unsafe.Pointer {
|
||||||
return reflect.ValueOf(args).UnsafePointer()
|
return reflect.ValueOf(args).UnsafePointer()
|
||||||
@@ -37,11 +48,32 @@ func CallerOf(args map[string]any) string {
|
|||||||
return Unproven
|
return Unproven
|
||||||
}
|
}
|
||||||
if c, ok := calls.Load(argsKey(args)); ok {
|
if c, ok := calls.Load(argsKey(args)); ok {
|
||||||
return c.(string)
|
return c.(inFlight).caller
|
||||||
}
|
}
|
||||||
return Unproven
|
return Unproven
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallIDOf is the id of the call that was handed args, as the runtime named it under MetaCall: what a ledger's apply
|
||||||
|
// names (buscalls.ApplyRequest.Call) so the operation's author is the caller the runtime proved. Empty for arguments
|
||||||
|
// no call was handed, or a call the runtime named no id for; read from `_meta`, never from the arguments.
|
||||||
|
func CallIDOf(args map[string]any) string {
|
||||||
|
if args == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if c, ok := calls.Load(argsKey(args)); ok {
|
||||||
|
return c.(inFlight).call
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// callIn is the call id a `tools/call`'s `_meta` names, or empty.
|
||||||
|
func callIn(meta map[string]any) string {
|
||||||
|
if s, ok := meta[MetaCall].(string); ok && callID.MatchString(s) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// callerIn is the caller a `tools/call`'s `_meta` names, or Unproven.
|
// callerIn is the caller a `tools/call`'s `_meta` names, or Unproven.
|
||||||
func callerIn(meta map[string]any) string {
|
func callerIn(meta map[string]any) string {
|
||||||
if s, ok := meta[MetaCaller].(string); ok && busUser.MatchString(s) {
|
if s, ok := meta[MetaCaller].(string); ok && busUser.MatchString(s) {
|
||||||
@@ -50,10 +82,10 @@ func callerIn(meta map[string]any) string {
|
|||||||
return Unproven
|
return Unproven
|
||||||
}
|
}
|
||||||
|
|
||||||
// runAs runs a tool with the caller held for its arguments for as long as it runs.
|
// runAs runs a tool with the caller and the call's id held for its arguments for as long as it runs.
|
||||||
func runAs(t Tool, caller string, args map[string]any) (any, error) {
|
func runAs(t Tool, caller, call string, args map[string]any) (any, error) {
|
||||||
key := argsKey(args)
|
key := argsKey(args)
|
||||||
calls.Store(key, caller)
|
calls.Store(key, inFlight{caller, call})
|
||||||
defer calls.Delete(key)
|
defer calls.Delete(key)
|
||||||
return t.Run(args)
|
return t.Run(args)
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-5
@@ -6,24 +6,33 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||||
)
|
)
|
||||||
|
|
||||||
// callWith serves one tool that answers the caller CallerOf reads from its arguments, sends it one tools/call
|
// callWith serves one tool that answers the caller CallerOf reads from its arguments, sends it one tools/call
|
||||||
// with the params given, and returns the caller it answered.
|
// with the params given, and returns the caller it answered.
|
||||||
func callWith(t *testing.T, params map[string]any) string {
|
func callWith(t *testing.T, params map[string]any) string {
|
||||||
|
caller, _ := callAnswering(t, params)
|
||||||
|
return caller
|
||||||
|
}
|
||||||
|
|
||||||
|
// callAnswering is callWith answering the call's id CallIDOf read too.
|
||||||
|
func callAnswering(t *testing.T, params map[string]any) (string, string) {
|
||||||
|
servesAlone(t)
|
||||||
t.Helper()
|
t.Helper()
|
||||||
inR, inW := io.Pipe()
|
inR, inW := io.Pipe()
|
||||||
outR, outW := io.Pipe()
|
outR, outW := io.Pipe()
|
||||||
go func() {
|
go func() {
|
||||||
_ = ServeOn("tracker", []Tool{{Name: "who", Run: func(args map[string]any) (any, error) {
|
_ = ServeOn("tracker", []Tool{{Name: "who", Run: func(args map[string]any) (any, error) {
|
||||||
return map[string]any{"caller": CallerOf(args)}, nil
|
return map[string]any{"caller": CallerOf(args), "call": CallIDOf(args)}, nil
|
||||||
}}}, inR, outW)
|
}}}, inR, outW)
|
||||||
}()
|
}()
|
||||||
defer inW.Close()
|
defer inW.Close()
|
||||||
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params})
|
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params})
|
||||||
_, _ = inW.Write(append(b, '\n'))
|
_, _ = inW.Write(append(b, '\n'))
|
||||||
lines := bufio.NewScanner(outR)
|
lines := bufio.NewScanner(outR)
|
||||||
done := make(chan string, 1)
|
done := make(chan [2]string, 1)
|
||||||
go func() {
|
go func() {
|
||||||
if lines.Scan() {
|
if lines.Scan() {
|
||||||
var m struct {
|
var m struct {
|
||||||
@@ -36,19 +45,20 @@ func callWith(t *testing.T, params map[string]any) string {
|
|||||||
_ = json.Unmarshal(lines.Bytes(), &m)
|
_ = json.Unmarshal(lines.Bytes(), &m)
|
||||||
var answer struct {
|
var answer struct {
|
||||||
Caller string `json:"caller"`
|
Caller string `json:"caller"`
|
||||||
|
Call string `json:"call"`
|
||||||
}
|
}
|
||||||
if len(m.Result.Content) > 0 {
|
if len(m.Result.Content) > 0 {
|
||||||
_ = json.Unmarshal([]byte(m.Result.Content[0].Text), &answer)
|
_ = json.Unmarshal([]byte(m.Result.Content[0].Text), &answer)
|
||||||
}
|
}
|
||||||
done <- answer.Caller
|
done <- [2]string{answer.Caller, answer.Call}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
case got := <-done:
|
case got := <-done:
|
||||||
return got
|
return got[0], got[1]
|
||||||
case <-time.After(3 * time.Second):
|
case <-time.After(3 * time.Second):
|
||||||
t.Fatal("the bundle did not answer the call")
|
t.Fatal("the bundle did not answer the call")
|
||||||
return ""
|
return "", ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +71,28 @@ func TestAToolReadsTheCallerTheRuntimeNamed(t *testing.T) {
|
|||||||
if got != "shanks.node-tools" {
|
if got != "shanks.node-tools" {
|
||||||
t.Fatalf("the tool read caller %q, want the one the runtime named", got)
|
t.Fatalf("the tool read caller %q, want the one the runtime named", got)
|
||||||
}
|
}
|
||||||
|
// The call's id is the runtime's too (ADR 0597 §7): read from `_meta`, never from the arguments, and none when it
|
||||||
|
// is not one token.
|
||||||
|
for _, c := range []struct {
|
||||||
|
args, meta map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{map[string]any{}, map[string]any{MetaCaller: "shanks.node-tools", MetaCall: "c-7Hq2"}, "c-7Hq2"},
|
||||||
|
{map[string]any{}, map[string]any{MetaCaller: "shanks.node-tools"}, ""},
|
||||||
|
{map[string]any{MetaCall: "c-forged"}, map[string]any{}, ""},
|
||||||
|
{map[string]any{}, map[string]any{MetaCall: "a b"}, ""},
|
||||||
|
{map[string]any{}, map[string]any{MetaCall: 7}, ""},
|
||||||
|
} {
|
||||||
|
if _, call := callAnswering(t, map[string]any{"name": "who", "arguments": c.args, "_meta": c.meta}); call != c.want {
|
||||||
|
t.Errorf("args %v, meta %v: the tool read call %q, want %q", c.args, c.meta, call, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if CallIDOf(map[string]any{"x": 1}) != "" || CallIDOf(nil) != "" {
|
||||||
|
t.Error("arguments no call was handed have a call id")
|
||||||
|
}
|
||||||
|
if MetaCall != buscalls.MetaCall {
|
||||||
|
t.Errorf("the call id's place is %q here and %q in buscalls", MetaCall, buscalls.MetaCall)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A caller named in the arguments is not the caller: an argument is the caller's to write.
|
// A caller named in the arguments is not the caller: an argument is the caller's to write.
|
||||||
@@ -75,6 +107,54 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) {
|
|||||||
if got != "shanks.node-tools" {
|
if got != "shanks.node-tools" {
|
||||||
t.Fatalf("with a caller in the arguments the tool read %q, want the runtime's", got)
|
t.Fatalf("with a caller in the arguments the tool read %q, want the runtime's", got)
|
||||||
}
|
}
|
||||||
|
// A module's trusted list of callers (issue 774, ADR 0563 §8) grants the callers it names as the runtime proved them:
|
||||||
|
// a module on any machine, <machine>.<module> exactly, person.<name> exactly; no wildcard, never one unproven.
|
||||||
|
callers, err := ParseCallers([]byte(`{"callers":["node-tools","ace.claude-code","person.jo","*"]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for caller, want := range map[string]bool{"shanks.node-tools": true, "ace.claude-code": true, "g14.claude-code": false,
|
||||||
|
"person.jo": true, "person.node-tools": false, Unproven: false, "": false, "novox.mesh-vault": false} {
|
||||||
|
if callers.Allows(caller) != want {
|
||||||
|
t.Errorf("%q allowed: %v, want %v", caller, !want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil {
|
||||||
|
t.Error("a list with a field it does not know was read")
|
||||||
|
}
|
||||||
|
// A holder's list per verb (ADR 0627 §2): each verb's entries as above, plus every proved caller (AnyCaller) or
|
||||||
|
// every operator's session (AnyPerson); an unproven caller never, a verb the holder does not serve refused.
|
||||||
|
verbs, err := ParseVerbCallers(`{"publish":["mesh-build-go"],"versions":["*"],"release":["operator","sweep"]}`,
|
||||||
|
[]string{"publish", "versions", "release", "remove"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
verb, caller string
|
||||||
|
want bool
|
||||||
|
}{{"publish", "ace.mesh-build-go", true}, {"publish", "ace.mesh-build-node", false}, {"versions", "g14.anything", true},
|
||||||
|
{"versions", Unproven, false}, {"release", "person.jo", true}, {"release", "ace.operator", false},
|
||||||
|
{"release", "novox.sweep", true}, {"remove", "novox.sweep", false}} {
|
||||||
|
if got := verbs.Allowed(c.verb, c.caller) == nil; got != c.want {
|
||||||
|
t.Errorf("%s by %q allowed: %v, want %v", c.verb, c.caller, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ParseVerbCallers(`{"mirror":["x"]}`, []string{"publish"}, nil); err == nil {
|
||||||
|
t.Error("a list naming a verb the holder does not serve was read")
|
||||||
|
}
|
||||||
|
if d, _ := ParseVerbCallers(" ", nil, VerbCallers{"publish": {"x"}}); len(d["publish"]) != 1 {
|
||||||
|
t.Error("an empty value is not the holder's defaults")
|
||||||
|
}
|
||||||
|
// As mesh-config resolves the value: the object itself, or written as a string; null is the defaults.
|
||||||
|
for _, raw := range []string{`{"publish":["a"]}`, `"{\"publish\":[\"a\"]}"`, `null`} {
|
||||||
|
c, err := parseConfiguredVerbCallers(json.RawMessage(raw), []string{"publish"}, VerbCallers{"publish": {"a"}})
|
||||||
|
if err != nil || c.Allowed("publish", "ace.a") != nil {
|
||||||
|
t.Errorf("the config value %s read as %v, %v", raw, c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ModuleOf("ace.mesh-build-go") != "mesh-build-go" {
|
||||||
|
t.Error("ModuleOf does not read the module of a proved caller")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is
|
// A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package stdio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Callers is a module's trusted list of who may call a verb of its (novox/hq ADR 0459 §2, ADR 0563 §8), as the mesh
|
||||||
|
// writes a list setting into a file the module reads: `{"callers": [...]}`. An entry is a module's name (that module on
|
||||||
|
// any machine), exactly <machine>.<module>, or exactly person.<name>. Each caller is the runtime's proof (CallerOf), never
|
||||||
|
// an argument; nobody beyond the list is granted, a wildcard is no entry, and an unproven caller never matches.
|
||||||
|
type Callers []string
|
||||||
|
|
||||||
|
// ParseCallers reads a callers file: the list under `callers`, and nothing else.
|
||||||
|
func ParseCallers(raw []byte) (Callers, error) {
|
||||||
|
var f struct {
|
||||||
|
Callers Callers `json:"callers"`
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&f); err != nil {
|
||||||
|
return nil, fmt.Errorf("the callers setting is not a list of callers: %v", err)
|
||||||
|
}
|
||||||
|
return f.Callers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allows says whether caller, as the runtime proved it, is one the list names.
|
||||||
|
func (c Callers) Allows(caller string) bool {
|
||||||
|
if caller == "" || caller == Unproven {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, entry := range c {
|
||||||
|
if entry == "" || strings.ContainsAny(entry, "*> ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry == caller {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.Contains(entry, ".") {
|
||||||
|
if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerbCallers is a holder's trusted list per verb (novox/hq ADR 0627 §2, design 51 *Who may call which verb*): for each
|
||||||
|
// verb of the seats it holds, the callers that may call it, each entry read as Callers reads it. Two entries more stand
|
||||||
|
// for a whole class: AnyCaller lets every proved caller call the verb (a read the mesh keeps open), AnyPerson every
|
||||||
|
// operator's session the runtime proved as person.<name>. It names no seat and no verb: the holder gives its verbs and
|
||||||
|
// its defaults, and keeps any rule of its own seat (who may never be on a list) beside it.
|
||||||
|
type VerbCallers map[string][]string
|
||||||
|
|
||||||
|
// The entries of a VerbCallers list that stand for a class of callers.
|
||||||
|
const (
|
||||||
|
AnyCaller = "*"
|
||||||
|
AnyPerson = "operator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseVerbCallers reads a holder's config value of verb to callers; an empty value is defaults. A verb not among verbs
|
||||||
|
// is refused, so a list that names a verb the holder does not serve is said at once.
|
||||||
|
func ParseVerbCallers(raw string, verbs []string, defaults VerbCallers) (VerbCallers, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return defaults, nil
|
||||||
|
}
|
||||||
|
var c VerbCallers
|
||||||
|
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||||
|
return nil, fmt.Errorf("callers is not a JSON object of verb to callers: %w", err)
|
||||||
|
}
|
||||||
|
for verb := range c {
|
||||||
|
if !slices.Contains(verbs, verb) {
|
||||||
|
return nil, fmt.Errorf("callers names the verb %q, which the holder does not serve", verb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed answers nil when caller, as the runtime proved it, may call verb; else the refusal naming both. An unproven
|
||||||
|
// caller is never allowed, not even by AnyCaller.
|
||||||
|
func (c VerbCallers) Allowed(verb, caller string) error {
|
||||||
|
if caller == "" || caller == Unproven {
|
||||||
|
return fmt.Errorf("%s: refused: the request carries no caller the tool runner proved", verb)
|
||||||
|
}
|
||||||
|
person := strings.HasPrefix(caller, "person.")
|
||||||
|
for _, entry := range c[verb] {
|
||||||
|
switch entry {
|
||||||
|
case AnyCaller:
|
||||||
|
return nil
|
||||||
|
case AnyPerson:
|
||||||
|
if person {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if (Callers{entry}).Allows(caller) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: refused for %s: not on the holder's caller list for %s", verb, caller, verb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleOf is the module of a proved module caller, which the runtime names <machine>.<module>.
|
||||||
|
func ModuleOf(proved string) string {
|
||||||
|
if _, module, ok := strings.Cut(proved, "."); ok {
|
||||||
|
return module
|
||||||
|
}
|
||||||
|
return proved
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfiguredVerbCallers is the holder's list as mesh-config resolved its config value key for this machine (Config):
|
||||||
|
// a JSON object of verb to callers, or that object written as a string; an unset or empty value is defaults. A value
|
||||||
|
// that cannot be read is an error, never the defaults, so a broken list is said, not widened.
|
||||||
|
func ConfiguredVerbCallers(key string, verbs []string, defaults VerbCallers) (VerbCallers, error) {
|
||||||
|
raw, err := Config(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the config value %s cannot be read: %w", key, err)
|
||||||
|
}
|
||||||
|
return parseConfiguredVerbCallers(raw, verbs, defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfiguredVerbCallers(raw json.RawMessage, verbs []string, defaults VerbCallers) (VerbCallers, error) {
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(raw, &text); err == nil || len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
||||||
|
return ParseVerbCallers(text, verbs, defaults)
|
||||||
|
}
|
||||||
|
return ParseVerbCallers(string(raw), verbs, defaults)
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +29,7 @@ func aServiceModule(t *testing.T) Manifest {
|
|||||||
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
|
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
|
||||||
"from": "cmd/svc", "binary": "svc"}]}
|
"from": "cmd/svc", "binary": "svc"}]}
|
||||||
}`
|
}`
|
||||||
m, err := ParseManifest([]byte(raw))
|
m, err := decodeForTest([]byte(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the service's manifest is refused: %v", err)
|
t.Fatalf("the service's manifest is refused: %v", err)
|
||||||
}
|
}
|
||||||
@@ -43,17 +41,6 @@ func aServiceModule(t *testing.T) Manifest {
|
|||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
func composeTheService(t *testing.T, with Rendering) []map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
with.Needed = map[string]map[string]string{"svc": {"store": "sealed-store"}}
|
|
||||||
with.ArtifactStore = "anchor.internal:5100"
|
|
||||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{aServiceModule(t)}}.Declaration(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("the service does not compose: %v", err)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexOf(out []map[string]any, id string) int {
|
func indexOf(out []map[string]any, id string) int {
|
||||||
for i, r := range out {
|
for i, r := range out {
|
||||||
if r["id"] == id {
|
if r["id"] == id {
|
||||||
@@ -88,79 +75,3 @@ func TestABundleItsOwnProcessRunsIsNotServedByTheRuntime(t *testing.T) {
|
|||||||
t.Fatalf("a tools bundle nothing runs is no longer served: %v", loads)
|
t.Fatalf("a tools bundle nothing runs is no longer served: %v", loads)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The account is created before anything is given to it. Its secrets are mesh-computed and so
|
|
||||||
// placed before the module's own resources; given to a user the machine did not have yet, they were
|
|
||||||
// refused on the first apply and the process started without them.
|
|
||||||
func TestAModulesAccountComesBeforeWhatBelongsToIt(t *testing.T) {
|
|
||||||
out := composeTheService(t, Rendering{})
|
|
||||||
account, secret := indexOf(out, "svc.account"), indexOf(out, "svc."+NeedID("store"))
|
|
||||||
if account < 0 || secret < 0 {
|
|
||||||
t.Fatalf("the account or the secret is missing: %v", out)
|
|
||||||
}
|
|
||||||
if account > secret {
|
|
||||||
t.Fatalf("the secret owned by svc is written before svc exists: account at %d, secret at %d",
|
|
||||||
account, secret)
|
|
||||||
}
|
|
||||||
if owner := out[secret]["owner"]; owner != "svc" {
|
|
||||||
t.Errorf("the secret belongs to %v, not the account its process runs as", owner)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The process is the module's program; its preparation is the same program asked to prepare, as a
|
|
||||||
// step before it — with the same account and environment, and handing nothing over.
|
|
||||||
func TestAProcessIsPreparedByItsOwnProgram(t *testing.T) {
|
|
||||||
out := composeTheService(t, Rendering{})
|
|
||||||
step, process := indexOf(out, "svc.service-prepare"), indexOf(out, "svc.service")
|
|
||||||
if step < 0 || process < 0 || step > process {
|
|
||||||
t.Fatalf("the preparation is not a step before the process (%d, %d): %v", step, process, out)
|
|
||||||
}
|
|
||||||
s := out[step]
|
|
||||||
if s["type"] != "process" || s["run-once"] != true || s["name"] != "svc-prepare" {
|
|
||||||
t.Errorf("the preparation is not a run-once process: %v", s)
|
|
||||||
}
|
|
||||||
if run, _ := json.Marshal(s["run"]); string(run) != `["./svc","prepare"]` {
|
|
||||||
t.Errorf("the preparation runs %s", run)
|
|
||||||
}
|
|
||||||
if s["user"] != "svc" || s["source"] != out[process]["source"] {
|
|
||||||
t.Errorf("the preparation does not run the same bundle as the same account: %v", s)
|
|
||||||
}
|
|
||||||
if env, _ := s["env"].(map[string]any); env["SVC_STORE_FILE"] == nil {
|
|
||||||
t.Errorf("the preparation is not given the process's environment: %v", s["env"])
|
|
||||||
}
|
|
||||||
if _, has := s["replaces"]; has {
|
|
||||||
t.Errorf("the preparation would hand over what the process replaces: %v", s)
|
|
||||||
}
|
|
||||||
if _, has := s["args"]; has {
|
|
||||||
t.Errorf("the preparation carries a container's args: %v", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the process replaces is named as the host recorded it, `<module>.<id>`; unprefixed, the host
|
|
||||||
// matches nothing and removes the container first, as before.
|
|
||||||
func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) {
|
|
||||||
out := composeTheService(t, Rendering{})
|
|
||||||
p := out[indexOf(out, "svc.service")]
|
|
||||||
if got, _ := json.Marshal(p["replaces"]); string(got) != `["svc.server"]` {
|
|
||||||
t.Fatalf("the process replaces %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) {
|
|
||||||
for what, resource := range map[string]string{
|
|
||||||
"a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`,
|
|
||||||
"a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`,
|
|
||||||
"something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`,
|
|
||||||
"another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`,
|
|
||||||
"not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`,
|
|
||||||
} {
|
|
||||||
raw := `{"module":"m","version":"1","resources":[` + resource + `]}`
|
|
||||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") {
|
|
||||||
t.Errorf("replaces on %s was accepted: %v", what, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}`
|
|
||||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
|
||||||
t.Errorf("a process replacing what its module no longer declares was refused: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the
|
|
||||||
// acquisition managers, a download client — that must share directories on one machine. Written
|
|
||||||
// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which
|
|
||||||
// is right in general and wrong here: sharing those directories is the whole point of the stack.
|
|
||||||
//
|
|
||||||
// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access
|
|
||||||
// is not a resource and never enters the duplicate-owner map, so this is the exact case the
|
|
||||||
// resolver refuses above when the same path is owned — and does not, when it is merely accessed.
|
|
||||||
func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) {
|
|
||||||
a := mod("a", nil, nil, nil)
|
|
||||||
a.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessReadWrite}}
|
|
||||||
b := mod("b", nil, nil, nil)
|
|
||||||
b.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessRead}}
|
|
||||||
|
|
||||||
got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("two modules accessing one operator-owned path were refused: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And each accessing module's grant reaches the machine as its own `access` resource, so the
|
|
||||||
// host can find the path present or refuse — both naming the one operator-owned path.
|
|
||||||
var accesses int
|
|
||||||
for _, r := range mustDeclare(t, got) {
|
|
||||||
if r["type"] == "access" && r["path"] == "/services/media/downloads" {
|
|
||||||
accesses++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if accesses != 2 {
|
|
||||||
t.Errorf("expected both modules' accesses in the declaration, got %d", accesses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside:
|
|
||||||
// two modules OWNING one path is still refused, exactly as before accesses existed.
|
|
||||||
func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) {
|
|
||||||
a := mod("a", nil, nil, nil)
|
|
||||||
a.Resources = []map[string]any{
|
|
||||||
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
|
|
||||||
b := mod("b", nil, nil, nil)
|
|
||||||
b.Resources = []map[string]any{
|
|
||||||
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
|
|
||||||
|
|
||||||
_, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("two modules owning the same directory were both assigned")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "/services/media/movies") {
|
|
||||||
t.Errorf("the refusal does not name the path: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path
|
|
||||||
// another module was told to expect the operator to provide would create and chown it — and the
|
|
||||||
// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction
|
|
||||||
// machine-checkable rather than a convention nobody enforces.
|
|
||||||
func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) {
|
|
||||||
owns := mod("owns", nil, nil, nil)
|
|
||||||
owns.Resources = []map[string]any{
|
|
||||||
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
|
|
||||||
uses := mod("uses", nil, nil, nil)
|
|
||||||
uses.Accesses = []Access{{Path: "/services/media/movies", Mode: AccessRead}}
|
|
||||||
|
|
||||||
_, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), World{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a module owning a path another accesses was allowed")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "/services/media/movies") ||
|
|
||||||
!strings.Contains(err.Error(), "the operator's") {
|
|
||||||
t.Errorf("the refusal does not explain the ownership conflict: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A manifest states an access's extent, the way a listening port states its source. An absolute
|
|
||||||
// path and a mode of read or read-write; anything else is refused rather than acted on wrongly.
|
|
||||||
func TestAnAccessIsValidated(t *testing.T) {
|
|
||||||
good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`)
|
|
||||||
if _, err := ParseManifest(good); err != nil {
|
|
||||||
t.Fatalf("a valid access was refused: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mode is optional and narrows to read — the safe default, because the danger with an access
|
|
||||||
// is being given more than was meant, not less.
|
|
||||||
bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`)
|
|
||||||
m, err := ParseManifest(bare)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("an access with no mode was refused: %v", err)
|
|
||||||
}
|
|
||||||
if m.Accesses[0].At() != AccessRead {
|
|
||||||
t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), AccessRead)
|
|
||||||
}
|
|
||||||
|
|
||||||
relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`)
|
|
||||||
if _, err := ParseManifest(relative); err == nil {
|
|
||||||
t.Error("a relative access path was accepted")
|
|
||||||
}
|
|
||||||
|
|
||||||
wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`)
|
|
||||||
if _, err := ParseManifest(wrong); err == nil {
|
|
||||||
t.Error("an access with an unknown mode was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user