Compare commits
518
Commits
fix/r4e-shelf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f9d11acf23 | ||
|
|
10b6e20232 | ||
|
|
da7f045575 | ||
|
|
748c564cdf | ||
|
|
312878befb | ||
|
|
d7a9b9688b | ||
|
|
7f9cc0e55c | ||
|
|
e45bdc0891 | ||
|
|
a523a8cbb0 | ||
|
|
5da87a0dda | ||
|
|
e64486b0e6 | ||
|
|
64a502d16a | ||
|
|
de5b33843f | ||
|
|
d8da8f1d36 | ||
|
|
d506f2196a | ||
|
|
d6cef5829b | ||
|
|
826fd73cf2 | ||
|
|
aee971c198 | ||
|
|
bfc3883af9 | ||
|
|
19c1d35939 | ||
|
|
bc0c25e510 | ||
|
|
8c6e18e822 | ||
|
|
d9fa5536a8 | ||
|
|
dd0d156b9e | ||
|
|
51bc6a65b6 | ||
|
|
66bf26c0d7 | ||
|
|
34f210e5eb | ||
|
|
4e0036cc95 | ||
|
|
4784762a70 | ||
|
|
668fc2a722 | ||
|
|
20d18eb4f5 | ||
|
|
e1f393bbb1 | ||
|
|
35e86b00d3 | ||
|
|
53a1de2aaa | ||
|
|
403a5d72ab | ||
|
|
1ff6d33978 | ||
|
|
a2c813a6b4 | ||
|
|
a9560890ee | ||
|
|
bec9585d50 | ||
|
|
fc4f57c4b3 | ||
|
|
88fa74ce5e | ||
|
|
9205222a98 | ||
|
|
285566eed1 | ||
|
|
51756ee9a4 | ||
|
|
d7375c9811 | ||
|
|
232ded26ea | ||
|
|
691c97ec31 | ||
|
|
ba092d543f | ||
|
|
c3915ffb11 | ||
|
|
34338df950 | ||
|
|
07462c4caa | ||
|
|
6003da0a58 | ||
|
|
3e5d4c30ff | ||
|
|
1672f40233 | ||
|
|
e1dca0a39f | ||
|
|
4b503f8d0d | ||
|
|
8cd22892a3 | ||
|
|
1f6fc027c0 | ||
|
|
f625d98713 | ||
|
|
c2967432e6 | ||
|
|
69041c5302 | ||
|
|
792fb2ccfd | ||
|
|
89aa6fc17c | ||
|
|
a1d73bdcf0 | ||
|
|
d9384bcb23 | ||
|
|
b9b9c5120c | ||
|
|
37eff6fe5c | ||
|
|
687d715e99 | ||
|
|
ec2bd3b990 | ||
|
|
2248b985bf | ||
|
|
780be2fae5 | ||
|
|
5ab7ccf47e | ||
|
|
6147a20454 | ||
|
|
7aacbc1620 |
@@ -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)
|
||||||
|
|||||||
@@ -571,3 +571,8 @@ type ProofAnswer struct {
|
|||||||
Accepted bool `json:"accepted"`
|
Accepted bool `json:"accepted"`
|
||||||
Words string `json:"words"`
|
Words string `json:"words"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259
|
||||||
|
// §2 to admit a node's engine as an asker): `node-engine-<node>`, the last token of the by-caller subjects granted to that
|
||||||
|
// node's engine account alone, so no engine acts on another node's warrant.
|
||||||
|
func EngineAsker(node string) string { return "node-engine-" + node }
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package buildstep
|
||||||
|
|
||||||
|
// CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how
|
||||||
|
// many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names
|
||||||
|
// a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config)
|
||||||
|
// lays the node's value into the module's settings file.
|
||||||
|
const CapacitySetting = "capacity"
|
||||||
@@ -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,114 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Option changes FetchCommit.
|
|
||||||
type Option func(*fetchOptions)
|
|
||||||
|
|
||||||
type fetchOptions struct{ allowFile bool }
|
|
||||||
|
|
||||||
// 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 } }
|
|
||||||
|
|
||||||
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 := 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
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkFetch(o fetchOptions, dir, remote, commit, ref string) error {
|
|
||||||
if dir == "" {
|
|
||||||
return fmt.Errorf("buildstep: no workspace directory")
|
|
||||||
}
|
|
||||||
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,112 +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
|
|
||||||
}
|
|
||||||
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,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,13 +0,0 @@
|
|||||||
package buildstep
|
|
||||||
|
|
||||||
// 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 }
|
|
||||||
@@ -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": "v0.1.7"}}, "no full commit"},
|
||||||
|
{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 {
|
||||||
|
|||||||
@@ -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
-79
@@ -1,29 +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"
|
|
||||||
"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
|
||||||
@@ -35,65 +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 := delivery.Step{ID: NewID(), Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC()}
|
|
||||||
key, err := delivery.StepKey(node, step.ID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := write(put, key, step); err != nil {
|
|
||||||
return "", fmt.Errorf("buildstep: writing the running step %s: %w", key, err)
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
result, err := protect(ctx, run)
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
for attempt := 1; ; attempt++ {
|
|
||||||
if werr := write(put, key, step); werr == nil || attempt >= EndAttempts {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(EndRetry)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return step.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
+7
-115
@@ -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
|
|
||||||
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
|
|
||||||
Paths map[string]string `json:"paths,omitempty"`
|
Paths map[string]string `json:"paths,omitempty"`
|
||||||
|
// Packages are no longer filled (ADR 0593 §2): kept so a preview written before still decodes.
|
||||||
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
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 or KindProvisioner.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintedKinds are the kinds of account the vault mints (ADR 0561 §1).
|
||||||
|
var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
func (w WantedAccount) OwnSecret() string {
|
||||||
|
switch w.Kind {
|
||||||
|
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,99 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(catalogue.EndedControllerName, 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,21 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 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,105 @@
|
|||||||
|
package buscompose
|
||||||
|
|
||||||
|
// 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 = "mesh-vault"
|
||||||
@@ -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,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
// 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 {
|
||||||
|
servesAlone(t)
|
||||||
t.Helper()
|
t.Helper()
|
||||||
inR, inW := io.Pipe()
|
inR, inW := io.Pipe()
|
||||||
outR, outW := io.Pipe()
|
outR, outW := io.Pipe()
|
||||||
@@ -75,6 +76,21 @@ 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 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
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package stdio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,18 +2,9 @@ package catalogue
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// What an adopted node is declared in place of a filter (novox/hq ADR 0100).
|
|
||||||
//
|
|
||||||
// On an adopted node the firewall found on the machine stays in force: the mesh loads no table
|
|
||||||
// that drops by default or holds an accept. What the mesh needs reachable is declared as
|
|
||||||
// openings, which the host converges through the found firewall in its own terms; and the mesh
|
|
||||||
// guards its own foundation ports itself, in a table that only refuses.
|
|
||||||
|
|
||||||
// AdoptionPrefix is the id prefix of what the mesh declares of its own on an adopted node. It is
|
// AdoptionPrefix is the id prefix of what the mesh declares of its own on an adopted node. It is
|
||||||
// never a module's, so none of it is ever held as found.
|
// never a module's, so none of it is ever held as found.
|
||||||
const AdoptionPrefix = "adoption."
|
const AdoptionPrefix = "adoption."
|
||||||
@@ -39,87 +30,9 @@ const (
|
|||||||
GuardUnitPath = "/etc/systemd/system/" + GuardUnit
|
GuardUnitPath = "/etc/systemd/system/" + GuardUnit
|
||||||
)
|
)
|
||||||
|
|
||||||
// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer
|
|
||||||
// raises the same three on an adopted genesis, so the first push finds them already there.
|
|
||||||
func GuardID() string { return AdoptionPrefix + "guard" }
|
|
||||||
func GuardUnitID() string { return AdoptionPrefix + "guard-unit" }
|
|
||||||
func GuardRunningID() string { return AdoptionPrefix + "guard-running" }
|
|
||||||
|
|
||||||
// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has
|
|
||||||
// no filter module and may have no nft at all, and a table nothing can load guards nothing.
|
|
||||||
func GuardPackageID() string { return AdoptionPrefix + "guard-package" }
|
|
||||||
|
|
||||||
// GuardPackage is the package that carries nft.
|
// GuardPackage is the package that carries nft.
|
||||||
const GuardPackage = "nftables"
|
const GuardPackage = "nftables"
|
||||||
|
|
||||||
// OpeningID is an opening's resource identity: its protocol, port and path say what it is.
|
|
||||||
func OpeningID(protocol string, port int, path string) string {
|
|
||||||
return fmt.Sprintf("%sopening-%s-%d-%s", AdoptionPrefix, protocol, port, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the
|
|
||||||
// filter it would load were the node converged, each from where that filter would admit it.
|
|
||||||
//
|
|
||||||
// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure —
|
|
||||||
// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine
|
|
||||||
// only opens nothing. `published` maps a machine port a container publishes to the container's
|
|
||||||
// port: a published port is forwarded, not received, so its opening names the forwarded path and
|
|
||||||
// the port the packet is forwarded to.
|
|
||||||
func Openings(rules []Rule, foundation []int, published map[string]map[int]int) []map[string]any {
|
|
||||||
type key struct {
|
|
||||||
protocol string
|
|
||||||
port int
|
|
||||||
}
|
|
||||||
from := map[key]string{}
|
|
||||||
var order []key
|
|
||||||
widen := func(k key, f string) {
|
|
||||||
was, seen := from[k]
|
|
||||||
if !seen {
|
|
||||||
order = append(order, k)
|
|
||||||
}
|
|
||||||
if !seen || was != OpeningFromEverywhere {
|
|
||||||
from[k] = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, rule := range rules {
|
|
||||||
switch rule.From {
|
|
||||||
case FromEverywhere:
|
|
||||||
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
|
|
||||||
case FromMesh:
|
|
||||||
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
|
|
||||||
case FromLocalNetwork:
|
|
||||||
// The private network's part only (novox/hq ADR 0340). An adopted node's found firewall
|
|
||||||
// stays, and it is what admits the machine's local network; an opening has no word for a
|
|
||||||
// local network, and widening it to everywhere would open the port to the internet.
|
|
||||||
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, port := range foundation {
|
|
||||||
widen(key{"tcp", port}, OpeningFromEverywhere)
|
|
||||||
}
|
|
||||||
sort.Slice(order, func(a, b int) bool {
|
|
||||||
if order[a].port != order[b].port {
|
|
||||||
return order[a].port < order[b].port
|
|
||||||
}
|
|
||||||
return order[a].protocol < order[b].protocol
|
|
||||||
})
|
|
||||||
out := make([]map[string]any, 0, len(order))
|
|
||||||
for _, k := range order {
|
|
||||||
opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol,
|
|
||||||
"from": from[k]}
|
|
||||||
if to, forwarded := published[k.protocol][k.port]; forwarded {
|
|
||||||
opening["id"] = OpeningID(k.protocol, k.port, PathForwarded)
|
|
||||||
opening["path"] = PathForwarded
|
|
||||||
opening["to"] = to
|
|
||||||
} else {
|
|
||||||
opening["id"] = OpeningID(k.protocol, k.port, PathIncoming)
|
|
||||||
opening["path"] = PathIncoming
|
|
||||||
}
|
|
||||||
out = append(out, opening)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Published is every port the given containers publish on the machine, by protocol and machine
|
// Published is every port the given containers publish on the machine, by protocol and machine
|
||||||
// port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off
|
// port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off
|
||||||
// the machine reaches it, forwarded or not.
|
// the machine reaches it, forwarded or not.
|
||||||
@@ -153,90 +66,3 @@ func Published(resources []map[string]any) map[string]map[int]int {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// AsGuard renders the mesh's refusal-only table for the given machine ports.
|
|
||||||
//
|
|
||||||
// It passes everything by default and holds nothing but a refusal, so it cannot close anything
|
|
||||||
// the machine serves; and it is the mesh's own table, so the found firewall reloading does not
|
|
||||||
// touch it. It refuses only packets addressed to this machine, and the ports except from the
|
|
||||||
// machine itself — its loopback and the container runtime's own networks — and from the private
|
|
||||||
// network, known by the interface a packet arrives on and never by its source address. At
|
|
||||||
// prerouting, ahead of the runtime's destination translation, so it matches the port the packet
|
|
||||||
// was sent to; in the inet family, so both address families.
|
|
||||||
//
|
|
||||||
// **The machine's own interfaces are named, where the derived filter names address ranges.** The
|
|
||||||
// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo,
|
|
||||||
// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is
|
|
||||||
// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name)
|
|
||||||
// would have its containers' traffic to a guarded port refused, which reads as the port being
|
|
||||||
// down. Names rather than addresses is deliberate: a source address can be claimed by whoever
|
|
||||||
// sends the packet, and this table exists to refuse what the found firewall never sees. Widening
|
|
||||||
// it means adding names here and in the installer's copy together, which the golden test holds to
|
|
||||||
// one text.
|
|
||||||
//
|
|
||||||
// The same text the installer raises on an adopted genesis; a test holds both to it.
|
|
||||||
func AsGuard(ports []int) string {
|
|
||||||
sorted := append([]int{}, ports...)
|
|
||||||
sort.Ints(sorted)
|
|
||||||
listed := make([]string, len(sorted))
|
|
||||||
for i, p := range sorted {
|
|
||||||
listed[i] = strconv.Itoa(p)
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("table inet mesh_guard {}\n")
|
|
||||||
b.WriteString("delete table inet mesh_guard\n")
|
|
||||||
b.WriteString("table inet mesh_guard {\n")
|
|
||||||
b.WriteString("\tchain prerouting {\n")
|
|
||||||
b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n")
|
|
||||||
// Only packets addressed to this machine: traffic it routes for others — a predecessor's hub,
|
|
||||||
// say — is never the guard's business (novox/hq ADR 0103).
|
|
||||||
fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+
|
|
||||||
"iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n",
|
|
||||||
strings.Join(listed, ", "))
|
|
||||||
b.WriteString("\t}\n")
|
|
||||||
b.WriteString("}\n")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never
|
|
||||||
// a flush, which would take the container runtime's rules and the found firewall with it.
|
|
||||||
func GuardUnitText() string {
|
|
||||||
return "[Unit]\n" +
|
|
||||||
"Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" +
|
|
||||||
// Early, before the network is up, and without the default dependencies that would
|
|
||||||
// order it after the network; stopped at shutdown like any unit.
|
|
||||||
"DefaultDependencies=no\n" +
|
|
||||||
"Wants=network-pre.target\n" +
|
|
||||||
"Before=network-pre.target shutdown.target\n" +
|
|
||||||
"Conflicts=shutdown.target\n" +
|
|
||||||
"\n" +
|
|
||||||
"[Service]\n" +
|
|
||||||
"Type=oneshot\n" +
|
|
||||||
"RemainAfterExit=yes\n" +
|
|
||||||
"ExecStart=nft -f " + GuardPath + "\n" +
|
|
||||||
"ExecReload=nft -f " + GuardPath + "\n" +
|
|
||||||
"ExecStop=nft delete table inet mesh_guard\n" +
|
|
||||||
"\n" +
|
|
||||||
"[Install]\n" +
|
|
||||||
"WantedBy=multi-user.target\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GuardResources are the guard as four resources of the existing kinds: the tool that loads it,
|
|
||||||
// the table, the unit, and the unit running — reloaded when the table changes, so the new table
|
|
||||||
// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and
|
|
||||||
// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty
|
|
||||||
// set is not a table nft loads.
|
|
||||||
func GuardResources(ports []int) []map[string]any {
|
|
||||||
if len(ports) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []map[string]any{
|
|
||||||
{"id": GuardPackageID(), "type": "package", "package": GuardPackage},
|
|
||||||
{"id": GuardID(), "type": "file", "path": GuardPath, "content": AsGuard(ports),
|
|
||||||
"mode": "0644"},
|
|
||||||
{"id": GuardUnitID(), "type": "file", "path": GuardUnitPath, "content": GuardUnitText(),
|
|
||||||
"mode": "0644"},
|
|
||||||
{"id": GuardRunningID(), "type": "service", "unit": GuardUnit, "state": "running",
|
|
||||||
"boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,14 +12,6 @@ import (
|
|||||||
// hub is the private network's generator on the hub: it opens the hub's port from anywhere.
|
// hub is the private network's generator on the hub: it opens the hub's port from anywhere.
|
||||||
type hub struct{}
|
type hub struct{}
|
||||||
|
|
||||||
func (hub) Resources(string) ([]map[string]any, bool, error) {
|
|
||||||
return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf",
|
|
||||||
"content": "[Interface]\n"}}, true, nil
|
|
||||||
}
|
|
||||||
func (hub) Listens(string) ([]Listening, error) {
|
|
||||||
return []Listening{{Port: 51820, Protocol: "udp", From: FromEverywhere}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
|
// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
|
||||||
// a served module and the filter module.
|
// a served module and the filter module.
|
||||||
func anAdoptedAnchor() Resolution {
|
func anAdoptedAnchor() Resolution {
|
||||||
@@ -51,24 +40,6 @@ func anAdoptedAnchor() Resolution {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func anchorRendering(adopted bool) Rendering {
|
|
||||||
return Rendering{
|
|
||||||
Generators: map[string]Generator{"overlay": hub{}},
|
|
||||||
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
|
|
||||||
Settings: SettingsBy{"distribution": {{From: "node anchor",
|
|
||||||
Placement: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
|
||||||
Mesh: []string{"10.42.0.1"},
|
|
||||||
Foundation: []int{5671},
|
|
||||||
// What the machine reported faces outside, which every rule in the filter is written
|
|
||||||
// around (novox/hq ADR 0140).
|
|
||||||
OutwardLinks: []string{"eth0"},
|
|
||||||
TunnelInterface: "mesh0",
|
|
||||||
Adopted: adopted,
|
|
||||||
// Genesis takes the foundation's modules.
|
|
||||||
Taken: map[string]bool{"postgres": true, "lavinmq": true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func byID(resources []map[string]any) map[string]map[string]any {
|
func byID(resources []map[string]any) map[string]map[string]any {
|
||||||
out := map[string]map[string]any{}
|
out := map[string]map[string]any{}
|
||||||
for _, r := range resources {
|
for _, r := range resources {
|
||||||
@@ -77,320 +48,6 @@ func byID(resources []map[string]any) map[string]map[string]any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) {
|
|
||||||
composed, err := anAdoptedAnchor().Compose(anchorRendering(true))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := byID(composed.Resources)
|
|
||||||
want := map[string]map[string]any{
|
|
||||||
// The hub's port, from anywhere, received.
|
|
||||||
"adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp",
|
|
||||||
"from": "everywhere", "path": "incoming"},
|
|
||||||
// The store's port from the private network only, and forwarded: a container publishes it.
|
|
||||||
"adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh",
|
|
||||||
"path": "forwarded", "to": 5432},
|
|
||||||
// The bus from anywhere: a node enrols over it before it has a private address.
|
|
||||||
"adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp",
|
|
||||||
"from": "everywhere", "path": "forwarded", "to": 5671},
|
|
||||||
"adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh",
|
|
||||||
"path": "forwarded", "to": 5672},
|
|
||||||
// The registry from anywhere, by its node's exposure setting.
|
|
||||||
"adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp",
|
|
||||||
"from": "everywhere", "path": "forwarded", "to": 5000},
|
|
||||||
// A published port names the machine port and the container port it is forwarded to.
|
|
||||||
"adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp",
|
|
||||||
"from": "everywhere", "path": "forwarded", "to": 8080},
|
|
||||||
}
|
|
||||||
for id, fields := range want {
|
|
||||||
opening, ok := got[id]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("no %s among %v", id, keys(got))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if opening["type"] != "opening" {
|
|
||||||
t.Errorf("%s is a %v", id, opening["type"])
|
|
||||||
}
|
|
||||||
for k, v := range fields {
|
|
||||||
if opening[k] != v {
|
|
||||||
t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for id := range got {
|
|
||||||
if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil {
|
|
||||||
t.Errorf("an opening nothing asked for: %s", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A port for this machine only opens nothing, and the management port is not opened at all.
|
|
||||||
for id := range got {
|
|
||||||
if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") {
|
|
||||||
t.Errorf("%s is opened", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// And openings come first, in the order the machine applies them.
|
|
||||||
if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") {
|
|
||||||
t.Errorf("openings are not first: %v", composed.Resources[0]["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) {
|
|
||||||
composed, err := anAdoptedAnchor().Compose(anchorRendering(true))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, r := range composed.Resources {
|
|
||||||
if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") {
|
|
||||||
t.Fatalf("an adopted node is declared the filter module's %v", r["id"])
|
|
||||||
}
|
|
||||||
if content, _ := r["content"].(string); strings.Contains(content, "policy drop") {
|
|
||||||
t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"])
|
|
||||||
}
|
|
||||||
// Nothing but refusals: the only accept in the guard is its policy.
|
|
||||||
if content, _ := r["content"].(string); r["id"] == GuardID() &&
|
|
||||||
strings.Count(content, "accept") != 1 {
|
|
||||||
t.Fatalf("the guard holds an accept:\n%s", content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
got := byID(composed.Resources)
|
|
||||||
guard := got[GuardID()]
|
|
||||||
if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil {
|
|
||||||
t.Fatalf("no guard: %v", keys(got))
|
|
||||||
}
|
|
||||||
if guard["content"] != AsGuard([]int{5432, 5672, 15672}) {
|
|
||||||
t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s",
|
|
||||||
guard["content"])
|
|
||||||
}
|
|
||||||
// The tool that loads it comes first, and the table after it: a node joining adopted has no
|
|
||||||
// filter module and may have no nft.
|
|
||||||
pkg, table := -1, -1
|
|
||||||
for i, r := range composed.Resources {
|
|
||||||
switch r["id"] {
|
|
||||||
case GuardPackageID():
|
|
||||||
pkg = i
|
|
||||||
if r["type"] != "package" || r["package"] != "nftables" {
|
|
||||||
t.Fatalf("the guard's package is %v", r)
|
|
||||||
}
|
|
||||||
case GuardID():
|
|
||||||
table = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pkg < 0 || pkg > table {
|
|
||||||
t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table)
|
|
||||||
}
|
|
||||||
// A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the
|
|
||||||
// table and leave the ports unguarded until it is loaded again. Only a changed unit restarts.
|
|
||||||
if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) ||
|
|
||||||
!reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) {
|
|
||||||
t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v",
|
|
||||||
got[GuardRunningID()])
|
|
||||||
}
|
|
||||||
// Nothing of the mesh's own is anybody's to hold.
|
|
||||||
for id, module := range composed.Owner {
|
|
||||||
if strings.HasPrefix(id, AdoptionPrefix) {
|
|
||||||
t.Fatalf("%s is owned by %s", id, module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) {
|
|
||||||
composed, err := anAdoptedAnchor().Compose(anchorRendering(false))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := byID(composed.Resources)
|
|
||||||
if got["nftables.filtering"] == nil || got["nftables.load"] == nil {
|
|
||||||
t.Fatalf("a converged node lost its filter: %v", keys(got))
|
|
||||||
}
|
|
||||||
for id := range got {
|
|
||||||
if strings.HasPrefix(id, AdoptionPrefix) {
|
|
||||||
t.Fatalf("a converged node is declared %s", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plain, err := anAdoptedAnchor().Declaration(anchorRendering(false))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
a, _ := json.Marshal(plain)
|
|
||||||
b, _ := json.Marshal(composed.Resources)
|
|
||||||
if string(a) != string(b) {
|
|
||||||
t.Fatal("Compose and Declaration disagree on a converged node")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The table the installer raises and the controller declares, character for character.
|
|
||||||
func TestTheGuardIsExactlyThisTable(t *testing.T) {
|
|
||||||
const golden = `table inet mesh_guard {}
|
|
||||||
delete table inet mesh_guard
|
|
||||||
table inet mesh_guard {
|
|
||||||
chain prerouting {
|
|
||||||
type filter hook prerouting priority raw; policy accept;
|
|
||||||
fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
if got := AsGuard([]int{15672, 5432}); got != golden {
|
|
||||||
t.Fatalf("the guard changed:\n%s", got)
|
|
||||||
}
|
|
||||||
const unit = `[Unit]
|
|
||||||
Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)
|
|
||||||
DefaultDependencies=no
|
|
||||||
Wants=network-pre.target
|
|
||||||
Before=network-pre.target shutdown.target
|
|
||||||
Conflicts=shutdown.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
RemainAfterExit=yes
|
|
||||||
ExecStart=nft -f /etc/mesh/guard.nft
|
|
||||||
ExecReload=nft -f /etc/mesh/guard.nft
|
|
||||||
ExecStop=nft delete table inet mesh_guard
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
`
|
|
||||||
if got := GuardUnitText(); got != unit {
|
|
||||||
t.Fatalf("the guard's unit changed:\n%s", got)
|
|
||||||
}
|
|
||||||
if GuardResources(nil) != nil {
|
|
||||||
t.Fatal("a guard with nothing to guard is an empty set nft refuses to load")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAGuardedPortMustBeAPort(t *testing.T) {
|
|
||||||
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil {
|
|
||||||
t.Fatalf("guards is refused: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil {
|
|
||||||
t.Fatal("guarding port 0 was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func keys[V any](m map[string]V) []string {
|
|
||||||
return sortedKeys(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place
|
|
||||||
// that uses the port reads it from there: the container, the filter, the openings, the guard.
|
|
||||||
func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
|
|
||||||
given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}}
|
|
||||||
for _, adopted := range []bool{true, false} {
|
|
||||||
with := anchorRendering(adopted)
|
|
||||||
with.Given = given
|
|
||||||
with.Ports["postgres"] = map[int]int{5432: 5433}
|
|
||||||
composed, err := anAdoptedAnchor().Compose(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := byID(composed.Resources)
|
|
||||||
if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) {
|
|
||||||
t.Fatalf("the store's container publishes %v", ports)
|
|
||||||
}
|
|
||||||
if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports,
|
|
||||||
[]any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) {
|
|
||||||
t.Fatalf("the broker's container publishes %v", ports)
|
|
||||||
}
|
|
||||||
if !adopted {
|
|
||||||
filter, _ := got["nftables.filtering"]["content"].(string)
|
|
||||||
if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") {
|
|
||||||
t.Fatalf("the filter does not use the given port:\n%s", filter)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 {
|
|
||||||
t.Fatalf("no opening for the given port: %v", keys(got))
|
|
||||||
}
|
|
||||||
if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) {
|
|
||||||
t.Fatalf("the guard does not guard the given ports:\n%s", guard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAGivenPortIsTheNodesAndReachesSomething(t *testing.T) {
|
|
||||||
store := anAdoptedAnchor().Modules[1]
|
|
||||||
node := func(v any) []Layer {
|
|
||||||
return []Layer{{From: "anchor", Placement: map[string]any{PortsSetting: v}}}
|
|
||||||
}
|
|
||||||
if got, err := GivenPorts(store, node(map[string]any{"5432": float64(5433)})); err != nil ||
|
|
||||||
got[5432] != 5433 {
|
|
||||||
t.Fatalf("a node's given port was not read: %v %v", got, err)
|
|
||||||
}
|
|
||||||
if _, err := GivenPorts(store, []Layer{{From: MeshWideLayer,
|
|
||||||
Placement: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil {
|
|
||||||
t.Fatal("a port given for the whole mesh was accepted")
|
|
||||||
}
|
|
||||||
if _, err := GivenPorts(store, node(map[string]any{"6000": float64(6001)})); err == nil {
|
|
||||||
t.Fatal("a port the module neither listens on, publishes nor guards was given")
|
|
||||||
}
|
|
||||||
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(70000)})); err == nil {
|
|
||||||
t.Fatal("a machine port that is not a port was given")
|
|
||||||
}
|
|
||||||
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(22)})); err == nil {
|
|
||||||
t.Fatal("ssh's port was given")
|
|
||||||
}
|
|
||||||
broker := anAdoptedAnchor().Modules[2]
|
|
||||||
if _, err := GivenPorts(broker, node(map[string]any{"5671": float64(5700),
|
|
||||||
"5672": float64(5700)})); err == nil {
|
|
||||||
t.Fatal("one machine port was given for two of the module's ports")
|
|
||||||
}
|
|
||||||
if stray := UnusedSettings(store, node(map[string]any{"5432": float64(5433)})); len(stray) != 0 {
|
|
||||||
t.Fatalf("a given port is called stray: %v", stray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken
|
|
||||||
// module publishes that the filter admits from the private network only, and the ports its
|
|
||||||
// manifest guards. A module assigned but not taken is not guarded: its port may still be the
|
|
||||||
// predecessor's.
|
|
||||||
func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) {
|
|
||||||
guardOf := func(with Rendering) string {
|
|
||||||
t.Helper()
|
|
||||||
composed, err := anAdoptedAnchor().Compose(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
content, _ := byID(composed.Resources)[GuardID()]["content"].(string)
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
// The broker taken, the store not: the broker's plain port follows from its listens (from
|
|
||||||
// the mesh, published), its management port from its manifest; the store is not guarded, and
|
|
||||||
// neither is the bus, which the mesh needs from everywhere.
|
|
||||||
with := anchorRendering(true)
|
|
||||||
with.Taken = map[string]bool{"lavinmq": true}
|
|
||||||
if got := guardOf(with); got != AsGuard([]int{5672, 15672}) {
|
|
||||||
t.Fatalf("the guard is not the taken broker's ports:\n%s", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A taken module publishing a port admitted from everywhere is not guarded; one admitted from
|
|
||||||
// the mesh is. The registry is exposed everywhere on this node, and hello-web listens from
|
|
||||||
// everywhere.
|
|
||||||
with.Taken = map[string]bool{"distribution": true, "hello-web": true}
|
|
||||||
if got := guardOf(with); got != "" {
|
|
||||||
t.Fatalf("a port admitted from everywhere is guarded:\n%s", got)
|
|
||||||
}
|
|
||||||
with.Settings = nil
|
|
||||||
if got := guardOf(with); got != AsGuard([]int{5000}) {
|
|
||||||
t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing taken, nothing guarded — and no guard at all rather than an empty set.
|
|
||||||
with = anchorRendering(true)
|
|
||||||
with.Taken = nil
|
|
||||||
if got := guardOf(with); got != "" {
|
|
||||||
t.Fatalf("an untaken store is guarded:\n%s", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A given port is followed: where the machine put it is what is refused.
|
|
||||||
with = anchorRendering(true)
|
|
||||||
with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}}
|
|
||||||
with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682}
|
|
||||||
if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) {
|
|
||||||
t.Fatalf("the guard does not follow the given ports:\n%s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A mapping bound to loopback is not published to anything off the machine — in either address
|
// A mapping bound to loopback is not published to anything off the machine — in either address
|
||||||
// family — and an address's own colons never shift the ports.
|
// family — and an address's own colons never shift the ports.
|
||||||
func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) {
|
func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) {
|
||||||
@@ -402,282 +59,3 @@ func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) {
|
|||||||
t.Fatalf("published is %v, want %v", got, want)
|
t.Fatalf("published is %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine
|
|
||||||
// itself listens where its software was told to, so giving it a machine port is refused.
|
|
||||||
func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
|
|
||||||
onTheMachine := Manifest{Module: "daemon",
|
|
||||||
Listens: []Listening{{Port: 9000, From: FromMesh}}, Guards: []int{9000}}
|
|
||||||
layers := []Layer{{From: "node anchor",
|
|
||||||
Placement: map[string]any{PortsSetting: map[string]any{"9000": float64(9100)}}}}
|
|
||||||
_, err := GivenPorts(onTheMachine, layers)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "does not publish") {
|
|
||||||
t.Fatalf("a port no container publishes was given: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded.
|
|
||||||
// An opening from everywhere beside a guard dropping it is one statement refusing the other.
|
|
||||||
func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) {
|
|
||||||
with := anchorRendering(true)
|
|
||||||
with.Settings["postgres"] = []Layer{{From: "node anchor",
|
|
||||||
Placement: map[string]any{ExposeSetting: map[string]any{"5432": FromEverywhere}}}}
|
|
||||||
composed, err := anAdoptedAnchor().Compose(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := byID(composed.Resources)
|
|
||||||
if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != OpeningFromEverywhere {
|
|
||||||
t.Fatalf("the store's port is not opened to everyone: %v", o)
|
|
||||||
}
|
|
||||||
if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) {
|
|
||||||
t.Fatalf("a port opened to everyone is still guarded:\n%s", guard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon
|
|
||||||
// holds 22 — and says it listens on the machine side of that mapping, which is what anything
|
|
||||||
// reaching it dials.
|
|
||||||
func aForge() Manifest {
|
|
||||||
return Manifest{Module: "forge",
|
|
||||||
Provides: []Offer{{Name: "git-over-ssh", Scope: ScopeMesh}},
|
|
||||||
Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}},
|
|
||||||
Listens: []Listening{{Port: 3000, From: FromMesh}, {Port: 2222, From: FromMesh}},
|
|
||||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge",
|
|
||||||
"ports": []any{"3000", "2222:22"}}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way
|
|
||||||
// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module
|
|
||||||
// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written
|
|
||||||
// here because everything below — the filter, the openings, the guard, what a consumer is told —
|
|
||||||
// reads that map, and a given port that the lookup does not find moves the container's mapping
|
|
||||||
// and nothing else.
|
|
||||||
//
|
|
||||||
// It stands in for the plan only where the plan does not allocate: a port the manifest already
|
|
||||||
// placed, or one a node was given. For a short form with no given port the real plan asks the
|
|
||||||
// inventory for a machine port and may come back with one from the pool, which needs a store and
|
|
||||||
// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts
|
|
||||||
// this helper, not the mesh; keep the assertions to the ports under test.
|
|
||||||
func portsAsThePlanWould(m Manifest, given map[int]int) map[int]int {
|
|
||||||
out := map[int]int{}
|
|
||||||
for _, l := range m.Listens {
|
|
||||||
if at, is := given[l.Port]; is {
|
|
||||||
out[l.Port] = at
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
at, _ := m.MachineSide(l.Port)
|
|
||||||
out[l.Port] = at
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module
|
|
||||||
// publishes, so a node may move it — and a module may name a mapping by either end.
|
|
||||||
func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
|
|
||||||
forge := aForge()
|
|
||||||
node := func(v any) []Layer {
|
|
||||||
return []Layer{{From: "node anchor", Placement: map[string]any{PortsSetting: v}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The machine side — the number this module says it listens on, and the one the predecessor
|
|
||||||
// had somewhere else. Answered under 2222, the end the module itself names, which is what
|
|
||||||
// every reader of this map asks for. One entry, not two: a second key for the same answer is
|
|
||||||
// an entry another mapping's reader could find instead.
|
|
||||||
given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
|
|
||||||
}
|
|
||||||
if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) {
|
|
||||||
t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The container's own port names the same mapping and means the same thing — and is filed
|
|
||||||
// under the same name, because the module's name for it has not changed.
|
|
||||||
if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil ||
|
|
||||||
!reflect.DeepEqual(inside, map[int]int{2222: 222}) {
|
|
||||||
t.Fatalf("the container's end of the mapping was given %v: %v", inside, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A short form is published on the number it names, and is unchanged by any of this.
|
|
||||||
if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil ||
|
|
||||||
short[3000] != 2999 {
|
|
||||||
t.Fatalf("the short form was given %v: %v", short, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A port no container publishes is still refused, in the same words.
|
|
||||||
if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "does not publish") {
|
|
||||||
t.Fatalf("a port the forge does not publish was given: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the two ends of one mapping given two different numbers is one setting contradicting
|
|
||||||
// the other: the machine publishes it once.
|
|
||||||
if _, err := GivenPorts(forge, node(map[string]any{
|
|
||||||
"2222": float64(222), "22": float64(300)})); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "one mapping") {
|
|
||||||
t.Fatalf("the two ends of one mapping were given different ports: %v", err)
|
|
||||||
}
|
|
||||||
// Said at both ends with the same number, it is still said twice, and refused where every
|
|
||||||
// other repeated machine port is — as the inventory refuses it when the setting is stored,
|
|
||||||
// which is the layer that sees it first.
|
|
||||||
if _, err := GivenPorts(forge, node(map[string]any{
|
|
||||||
"2222": float64(222), "22": float64(222)})); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "to both its 22 and its 2222") {
|
|
||||||
t.Fatalf("one mapping given one machine port at both ends: %v", err)
|
|
||||||
}
|
|
||||||
// A number that names two different mappings names neither: which one to move is not said.
|
|
||||||
twice := aForge()
|
|
||||||
twice.Resources[0]["ports"] = []any{"22", "2222:22"}
|
|
||||||
if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "twice") {
|
|
||||||
t.Fatalf("a number naming two of the module's mappings was accepted: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the same refusal when the number naming two mappings is not the one the setting used
|
|
||||||
// but the one the answer would be filed under. Here `80` is the module's own name for a
|
|
||||||
// mapping, and two mappings wear it; filing an answer there is one container's port standing
|
|
||||||
// where the other's is read, and both containers then publish it.
|
|
||||||
shared := Manifest{Module: "gallery",
|
|
||||||
Listens: []Listening{{Port: 80, From: FromMesh}},
|
|
||||||
Resources: []map[string]any{
|
|
||||||
{"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}},
|
|
||||||
{"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}}
|
|
||||||
if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "twice") {
|
|
||||||
t.Fatalf("two containers were put on one machine port: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module whose mappings chain — one's machine side is another's container port — keeps them
|
|
||||||
// apart, because each is filed under the port the module names it by and neither name is
|
|
||||||
// shared. Given both, each moves on its own and neither overwrites the other.
|
|
||||||
chained := Manifest{Module: "chain",
|
|
||||||
Listens: []Listening{{Port: 80, From: FromMesh}, {Port: 9090, From: FromMesh}},
|
|
||||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain",
|
|
||||||
"ports": []any{"8080:80", "9090:8080"}}}}
|
|
||||||
both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)}))
|
|
||||||
if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) {
|
|
||||||
t.Fatalf("chained mappings were given %v: %v", both, err)
|
|
||||||
}
|
|
||||||
if moved := givenOuter("8080:80", both); moved != "1234:80" {
|
|
||||||
t.Fatalf("the first mapping moved to %q, and it was given 1234", moved)
|
|
||||||
}
|
|
||||||
if moved := givenOuter("9090:8080", both); moved != "5678:8080" {
|
|
||||||
t.Fatalf("the second mapping moved to %q, and it was given 5678", moved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the number reaches everything derived from it. The fault this is written against moved the
|
|
||||||
// container's mapping alone: the filter opened the port the software had left, the adopted node's
|
|
||||||
// opening named it too, the guard refused it, and a consumer was sent to it.
|
|
||||||
func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) {
|
|
||||||
forge := aForge()
|
|
||||||
given, err := GivenPorts(forge, []Layer{{From: "node anchor",
|
|
||||||
Placement: map[string]any{PortsSetting: map[string]any{"2222": float64(222)}}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
|
|
||||||
}
|
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
|
||||||
with := Rendering{
|
|
||||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
|
|
||||||
Given: map[string]map[int]int{"forge": given},
|
|
||||||
Mesh: []string{"10.77.0.1"},
|
|
||||||
Adopted: true,
|
|
||||||
OutwardLinks: []string{"eth0"},
|
|
||||||
TunnelInterface: "mesh0",
|
|
||||||
Taken: map[string]bool{"forge": true},
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the runtime is handed: the machine's own port on the outside, the container's within.
|
|
||||||
composed, err := r.Compose(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("the forge does not compose: %v", err)
|
|
||||||
}
|
|
||||||
got := byID(composed.Resources)
|
|
||||||
if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) {
|
|
||||||
t.Fatalf("the forge's container publishes %v", ports)
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the filter would open, were the node converged.
|
|
||||||
rules, err := r.Rules(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var opened []int
|
|
||||||
for _, rule := range rules {
|
|
||||||
opened = append(opened, rule.Port)
|
|
||||||
}
|
|
||||||
// Only the moved port is asserted: the forge's other port is a short form the real plan would
|
|
||||||
// allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and
|
|
||||||
// not the mesh's.
|
|
||||||
if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) {
|
|
||||||
t.Fatalf("the filter opens %v, not where the machine puts the forge", opened)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And what it is declared instead, adopted: an opening on the machine's port, naming the
|
|
||||||
// container's port on the forwarded path — a published port is forwarded, never received.
|
|
||||||
opening := got[OpeningID("tcp", 222, PathForwarded)]
|
|
||||||
if opening == nil || opening["to"] != 22 || opening["from"] != OpeningFromMesh {
|
|
||||||
t.Fatalf("no opening for the port this node gave the forge: %v", keys(got))
|
|
||||||
}
|
|
||||||
for id := range got {
|
|
||||||
if strings.HasPrefix(id, "adoption.opening-tcp-2222-") {
|
|
||||||
t.Errorf("an opening for the port the forge was moved off: %s", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The guard refuses it where the machine put it, and nothing where it used to be.
|
|
||||||
guard, _ := got[GuardID()]["content"].(string)
|
|
||||||
if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") {
|
|
||||||
t.Fatalf("the guard does not follow the given port:\n%s", guard)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And a consumer is sent to the same number, which is read from what the module serves.
|
|
||||||
if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 {
|
|
||||||
t.Fatalf("a consumer is told the forge answers on %v", told)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the mapping itself moves under either name, because Rendering.Given is a map anybody
|
|
||||||
// composing a declaration hands in: keyed by the machine side, which is what a module declaring
|
|
||||||
// 2222 calls its port, only the outside moves and the container's own port stays as written.
|
|
||||||
func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) {
|
|
||||||
for _, c := range []struct {
|
|
||||||
written string
|
|
||||||
given map[int]int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"2222:22", map[int]int{2222: 222}, "222:22"},
|
|
||||||
{"2222:22", map[int]int{22: 222}, "222:22"},
|
|
||||||
{"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"},
|
|
||||||
{"2222:22", map[int]int{3000: 2999}, "2222:22"},
|
|
||||||
{"2222:22", nil, "2222:22"},
|
|
||||||
} {
|
|
||||||
if got := givenOuter(c.written, c.given); got != c.want {
|
|
||||||
t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same on a node that was given nothing, which is where the derivation was never at fault:
|
|
||||||
// a long-form mapping is published where the manifest put it, and an adopted node opens that port
|
|
||||||
// on the forwarded path like any other. What broke it was the number reaching only the mapping.
|
|
||||||
func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) {
|
|
||||||
forge := aForge()
|
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
|
||||||
composed, err := r.Compose(Rendering{
|
|
||||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
|
|
||||||
Mesh: []string{"10.77.0.1"},
|
|
||||||
Adopted: true,
|
|
||||||
OutwardLinks: []string{"eth0"},
|
|
||||||
TunnelInterface: "mesh0",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := byID(composed.Resources)
|
|
||||||
opening := got[OpeningID("tcp", 2222, PathForwarded)]
|
|
||||||
if opening == nil || opening["to"] != 22 {
|
|
||||||
t.Fatalf("no opening for the forge's published ssh port: %v", keys(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The account agents run as (novox/hq ADR 0266): a module names it as a machine fact — the agent account
|
|
||||||
// where the node names one, the operator's otherwise — and asks the node-engine to judge it never to become
|
|
||||||
// root only where it is the agents' own.
|
|
||||||
|
|
||||||
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
|
|
||||||
facts := machineFacts(Resolution{Node: "anchor", Account: "ops"}, nil, "")
|
|
||||||
if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
|
|
||||||
t.Errorf("with no agent account named, agents run as the operator: %v", facts)
|
|
||||||
}
|
|
||||||
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
|
|
||||||
if facts["agent-home"] != "/srv/ops" {
|
|
||||||
t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
|
|
||||||
}
|
|
||||||
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
|
|
||||||
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != RootNever {
|
|
||||||
t.Errorf("a named agent account is the agents', never root: %v", facts)
|
|
||||||
}
|
|
||||||
if facts["account"] != "ops" {
|
|
||||||
t.Errorf("the operator account is still the operator's: %v", facts)
|
|
||||||
}
|
|
||||||
facts = machineFacts(Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
|
|
||||||
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != RootNever {
|
|
||||||
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
|
|
||||||
}
|
|
||||||
if _, has := machineFacts(Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
|
|
||||||
t.Error("a machine with no account at all names an agent account")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
|
|
||||||
// own; its directory under that home, owned by it.
|
|
||||||
const agentModule = `{"module": "agent", "version": "1", "resources": [
|
|
||||||
{"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}",
|
|
||||||
"root": "${machine:agent-root}"},
|
|
||||||
{"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
|
|
||||||
"owner": "${machine:agent-account}"}
|
|
||||||
]}`
|
|
||||||
|
|
||||||
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
|
|
||||||
m, err := ParseManifest([]byte(agentModule))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
compose := func(r Resolution, with Rendering) (user, home map[string]any) {
|
|
||||||
t.Helper()
|
|
||||||
r.Node, r.Modules = "anchor", []Manifest{m}
|
|
||||||
out, err := r.Declaration(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
|
|
||||||
}
|
|
||||||
|
|
||||||
user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true})
|
|
||||||
if user["name"] != "agent" || user[RootField] != RootNever || user["home"] != "/home/agent" {
|
|
||||||
t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
|
|
||||||
}
|
|
||||||
if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
|
|
||||||
t.Errorf("the agent's directory is under its own home, its own: %v", home)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, Rendering{JudgesRoot: true})
|
|
||||||
if user["home"] != "/srv/agent" {
|
|
||||||
t.Errorf("an agent account named with a home of its own is made there: %v", user)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{})
|
|
||||||
if _, sent := user[RootField]; sent || user["name"] != "agent" {
|
|
||||||
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, home = compose(Resolution{Account: "ops"}, Rendering{JudgesRoot: true})
|
|
||||||
if _, sent := user[RootField]; sent || user["name"] != "ops" {
|
|
||||||
t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
|
|
||||||
}
|
|
||||||
if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
|
|
||||||
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) {
|
|
||||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
|
||||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
|
||||||
envOf := func(r Resolution) map[string]string {
|
|
||||||
t.Helper()
|
|
||||||
r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)}
|
|
||||||
out, err := r.Declaration(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
|
||||||
if process == nil {
|
|
||||||
t.Fatal("no runtime process was composed")
|
|
||||||
}
|
|
||||||
return process["env"].(map[string]string)
|
|
||||||
}
|
|
||||||
env := envOf(Resolution{Account: "ops", AgentAccount: "agent"})
|
|
||||||
if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" {
|
|
||||||
t.Errorf("the runtime is not told whom agents run as: %v", env)
|
|
||||||
}
|
|
||||||
env = envOf(Resolution{Account: "ops"})
|
|
||||||
if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" {
|
|
||||||
t.Errorf("with no agent account, agents run as the operator: %v", env)
|
|
||||||
}
|
|
||||||
env = envOf(Resolution{})
|
|
||||||
if _, set := env[RuntimeAgentAccount]; set {
|
|
||||||
t.Errorf("a machine with no account names an agent account: %v", env)
|
|
||||||
}
|
|
||||||
if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"},
|
|
||||||
Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 {
|
|
||||||
t.Error("a bundle may tell the runtime whom agents run as")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,22 +14,6 @@ import (
|
|||||||
// the old wire protocol asks for the one server being retired, so both directions are refused at the
|
// the old wire protocol asks for the one server being retired, so both directions are refused at the
|
||||||
// parser — this is judged from the manifest alone, no store needed.
|
// parser — this is judged from the manifest alone, no store needed.
|
||||||
|
|
||||||
func TestAManifestProvidingAmqpIsRefused(t *testing.T) {
|
|
||||||
raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`)
|
|
||||||
_, err := ParseManifest(raw)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) {
|
|
||||||
t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAManifestRequiringAmqpIsRefused(t *testing.T) {
|
|
||||||
raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`)
|
|
||||||
_, err := ParseManifest(raw)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) {
|
|
||||||
t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under
|
// And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under
|
||||||
// design 28 task 5.4, not converted.
|
// design 28 task 5.4, not converted.
|
||||||
func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
|
func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// A claim written before the rename still holds (novox/hq ADR 0122, ADR 0156): with the store's
|
|
||||||
// aliases loaded, the former name resolves to the seat.
|
|
||||||
func TestTheArtifactStoresFormerNameResolvesToIt(t *testing.T) {
|
|
||||||
was := aliases
|
|
||||||
t.Cleanup(func() { aliases = was })
|
|
||||||
UseAliases(map[string]string{"the-artifact-store": "mesh-artifact-store"})
|
|
||||||
seat, known := SeatNamed("the-artifact-store")
|
|
||||||
if !known || seat.Name != "mesh-artifact-store" || seat.Delivers != "artifact-store" {
|
|
||||||
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
|
|
||||||
}
|
|
||||||
if _, known := SeatNamed("mesh-artifact-store"); !known {
|
|
||||||
t.Fatal("the seat is not in the set under its name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The artifact store's seat is one per mesh, read from the catalogue beside this checkout.
|
|
||||||
//
|
|
||||||
// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat
|
|
||||||
// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a
|
|
||||||
// node-scoped requirement with one candidate installs that candidate on the node, so anything that
|
|
||||||
// required the store's presence beside it would have raised a fresh, empty store on the wrong
|
|
||||||
// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every
|
|
||||||
// consumer elsewhere refusing to choose. The claim says it first, where the second store is
|
|
||||||
// assigned.
|
|
||||||
func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
|
|
||||||
store := catalogueManifest(t, "distribution")
|
|
||||||
|
|
||||||
// The first store resolves as it always has.
|
|
||||||
if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), World{}); err != nil {
|
|
||||||
t.Fatalf("the store alone does not resolve: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A second one, on any other machine, is refused — and the refusal names the seat.
|
|
||||||
elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh,
|
|
||||||
Node: "anchor", Module: "distribution"}}}
|
|
||||||
other := workstation()
|
|
||||||
other.Name = "laptop"
|
|
||||||
_, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
|
|
||||||
"second time and every consumer elsewhere would refuse to choose")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
|
|
||||||
t.Fatalf("refused without naming the seat: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
// ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by
|
||||||
|
// distribution, and not the provision artifact-store it delivers.
|
||||||
|
const ArtifactSweepSeat = "mesh-artifact-sweep"
|
||||||
|
|
||||||
|
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
|
||||||
|
func artifactSweepVerbs() []Verb {
|
||||||
|
return []Verb{
|
||||||
|
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
|
||||||
|
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
|
||||||
|
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
|
||||||
|
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
|
||||||
|
"collected": "\"true\": list the collected references too (counted always)",
|
||||||
|
}, nil, "collected")},
|
||||||
|
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
|
||||||
|
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
|
||||||
|
"Reads only.",
|
||||||
|
Input: listed(schema(map[string]string{
|
||||||
|
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
|
||||||
|
"or one text separated by commas",
|
||||||
|
}, []string{"references"}), "references")},
|
||||||
|
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
|
||||||
|
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
|
||||||
|
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
|
||||||
|
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
|
||||||
|
"seconds; what is left is said. Never touches a digest the mesh did not make.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
|
||||||
|
"why": "with confirm: why, required and recorded",
|
||||||
|
"most": "let go of at most this many (500 by default, at most 5000)",
|
||||||
|
}, nil, "confirm")},
|
||||||
|
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
|
||||||
|
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
|
||||||
|
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
|
||||||
|
"naming it: what the machine keeps when it prunes its images. Reads only.",
|
||||||
|
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,9 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// What the mesh built, named by what it is rather than by where it was pushed.
|
|
||||||
//
|
|
||||||
// **An image is recorded by its digest and its path; the registry's address is a route to it**
|
|
||||||
// (novox/hq 04-ISSUES/102). A build used to be recorded as `<registry>:<port>/<module>/<artifact>@sha256:…`
|
|
||||||
// — the reference the builder pushed to, kept whole — and every declaration carried that literal.
|
|
||||||
// Move the registry's port, or the registry, and every fresh pull of a mesh image fails: a new
|
|
||||||
// node, a recreate after eviction. The digest is the identity; the address is the node's setting
|
|
||||||
// for the module that serves the artifact store, and it is read from there when a reference is
|
|
||||||
// composed, never written into a record.
|
|
||||||
//
|
|
||||||
// So a kept reference has a scheme of the mesh's own, named after the provision that answers it:
|
|
||||||
//
|
|
||||||
// artifact-store://<module>/<artifact>@sha256:<hex> an image
|
|
||||||
// artifact-store://<module>/<artifact>/blobs/sha256:<hex> an archive
|
|
||||||
//
|
|
||||||
// No runtime knows the scheme. That is the point of it being one: a reference that leaks to a
|
|
||||||
// machine uncomposed is refused by the runtime as malformed, in front of whoever sent it, rather
|
|
||||||
// than pulled from a public registry that happens to have a repository by that name — which is
|
|
||||||
// what an address-less `<module>/<artifact>@sha256:…` would be.
|
|
||||||
|
|
||||||
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
|
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
|
||||||
// the store's address.
|
// the store's address.
|
||||||
const ArtifactStoreScheme = ArtifactStoreProvision + "://"
|
const ArtifactStoreScheme = ArtifactStoreProvision + "://"
|
||||||
@@ -77,69 +56,3 @@ func Routed(reference, address string) string {
|
|||||||
}
|
}
|
||||||
return address + "/" + path
|
return address + "/" + path
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rerouted is a reference the mesh recorded, whichever way it was recorded, as a machine fetches
|
|
||||||
// it now: a kept one composed with the store's address, and one recorded before references were
|
|
||||||
// kept without their address — the builder's own `<host>/<path>@sha256:…` — re-routed to where
|
|
||||||
// the store is now. Only for references that are the mesh's own: everything a build record
|
|
||||||
// holds is, by construction.
|
|
||||||
func Rerouted(reference, address string) string {
|
|
||||||
return Routed(Recorded(reference), address)
|
|
||||||
}
|
|
||||||
|
|
||||||
// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
|
|
||||||
//
|
|
||||||
// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is
|
|
||||||
// routed through the store as this network reaches it now. A reference recorded with an address
|
|
||||||
// before references were kept without one is re-routed the same way — but only when the mesh
|
|
||||||
// built it (`with.Built` names every `<module>/<artifact>` it has), because a module may run an
|
|
||||||
// image from a public registry under its own name and that one is exactly where it says.
|
|
||||||
//
|
|
||||||
// A kept reference with no store to route it through is refused: sent as it is, the runtime would
|
|
||||||
// refuse the scheme on the machine, one push away from the reason.
|
|
||||||
//
|
|
||||||
// **What this does not reach: the images genesis pinned.** The installer builds the control plane
|
|
||||||
// and the builder before the mesh exists, pushes them itself and pins their manifests to
|
|
||||||
// `<registry>:<port>/mesh-controller@…` and `<registry>:<port>/mesh-builder@…` — single-segment
|
|
||||||
// repositories with no build record, so `with.Built` does not name them and they are left as
|
|
||||||
// written until each is rebuilt through the mesh, which records it by digest and path. Until then
|
|
||||||
// a registry that moves strands exactly those two on a recreate, and the control plane's is the
|
|
||||||
// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the
|
|
||||||
// store (novox/hq 04-ISSUES/102, finding F4).
|
|
||||||
func artifactsInto(resource map[string]any, module string, with Rendering) error {
|
|
||||||
for _, key := range []string{"image", "source"} {
|
|
||||||
written, ok := resource[key].(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, kept := InArtifactStore(written); kept {
|
|
||||||
if with.ArtifactStore == "" {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+
|
|
||||||
"artifact store on its network to fetch it from — nothing assigned offers "+
|
|
||||||
"%s, or the machine offering it is not on the private network",
|
|
||||||
module, resource["id"], written, ArtifactStoreProvision)
|
|
||||||
}
|
|
||||||
resource[key] = Routed(written, with.ArtifactStore)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if with.ArtifactStore == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
recorded := Recorded(written)
|
|
||||||
if recorded == written {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path, _ := InArtifactStore(recorded)
|
|
||||||
repository := path
|
|
||||||
if at := strings.IndexAny(path, "@"); at >= 0 {
|
|
||||||
repository = path[:at]
|
|
||||||
} else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 {
|
|
||||||
repository = path[:blobs]
|
|
||||||
}
|
|
||||||
if with.Built[repository] {
|
|
||||||
resource[key] = Routed(recorded, with.ArtifactStore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,111 +27,3 @@ func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestARecordedReferenceIsRoutedThroughTheStoreAsItIsNow(t *testing.T) {
|
|
||||||
if got := Routed(ArtifactStoreScheme+"gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest {
|
|
||||||
t.Errorf("an image is fetched as %q", got)
|
|
||||||
}
|
|
||||||
if got := Routed(ArtifactStoreScheme+"gitea/config/blobs/"+digest, "anchor.internal:5101"); got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
|
|
||||||
t.Errorf("an archive is fetched as %q", got)
|
|
||||||
}
|
|
||||||
if got := Routed("gitea/gitea@"+digest, "anchor.internal:5101"); got != "gitea/gitea@"+digest {
|
|
||||||
t.Errorf("a reference that is not the store's was routed: %q", got)
|
|
||||||
}
|
|
||||||
// One recorded before references were kept without their address follows the store too.
|
|
||||||
if got := Rerouted("anchor.internal:5100/gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest {
|
|
||||||
t.Errorf("a reference recorded with the old address stays there: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **The address is composed into a declaration, and the record never carries it.**
|
|
||||||
func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
|
|
||||||
m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
|
|
||||||
{"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"},
|
|
||||||
{"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"},
|
|
||||||
{"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest},
|
|
||||||
}, Build: &Build{Artifacts: []Artifact{
|
|
||||||
{Name: "server", Kind: ArtifactImage, From: "Dockerfile"},
|
|
||||||
{Name: "config", Kind: ArtifactArchive, From: "config"},
|
|
||||||
}}}
|
|
||||||
resolved, err := m.Resolve([]Built{
|
|
||||||
{Name: "server", Kind: ArtifactImage, Reference: ArtifactStoreScheme + "gitea/server@" + digest},
|
|
||||||
{Name: "config", Kind: ArtifactArchive, Reference: ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{resolved}}
|
|
||||||
|
|
||||||
out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest {
|
|
||||||
t.Errorf("the image the mesh built is fetched as %v", got)
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
|
|
||||||
t.Errorf("the archive the mesh built is fetched from %v", got)
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest {
|
|
||||||
t.Errorf("an image from a public registry was routed through the store: %v", got)
|
|
||||||
}
|
|
||||||
// The manifest the mesh holds still says what it is, not where it was fetched from.
|
|
||||||
if got := resolved.Resources[0]["image"]; got != ArtifactStoreScheme+"gitea/server@"+digest {
|
|
||||||
t.Errorf("composing wrote the address into the catalogue's copy: %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the store moves: the same record, another address, without a rebuild.
|
|
||||||
out, err = r.Declaration(Rendering{ArtifactStore: "laptop.internal:5000"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest {
|
|
||||||
t.Errorf("after the store moved, the image is still fetched as %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) {
|
|
||||||
m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
|
|
||||||
{"id": "server", "type": "container", "name": "mesh-gitea",
|
|
||||||
"image": ArtifactStoreScheme + "gitea/server@" + digest},
|
|
||||||
}}
|
|
||||||
_, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(Rendering{})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "no artifact store") {
|
|
||||||
t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A reference recorded with an address before this follows the store too** — when the mesh
|
|
||||||
// built it. A module running an image straight from a public registry under its own name is left
|
|
||||||
// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called.
|
|
||||||
func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) {
|
|
||||||
m := Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{
|
|
||||||
{"id": "server", "type": "container", "name": "mesh-keycloak",
|
|
||||||
"image": "anchor.internal:5100/keycloak/server@" + digest},
|
|
||||||
{"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream",
|
|
||||||
"image": "quay.io/keycloak/keycloak@" + digest},
|
|
||||||
}}
|
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{m}}
|
|
||||||
out, err := r.Declaration(Rendering{
|
|
||||||
ArtifactStore: "anchor.internal:5101",
|
|
||||||
Built: map[string]bool{"keycloak/server": true},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest {
|
|
||||||
t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got)
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest {
|
|
||||||
t.Errorf("a public image was re-routed through the store: %v", got)
|
|
||||||
}
|
|
||||||
// Nothing known to be built: nothing re-routed, nothing refused.
|
|
||||||
out, err = r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest {
|
|
||||||
t.Errorf("with no build record, a reference was rewritten: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,53 +1,4 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the machine's modules back up is placement (design 57 L3, issue 794). The holder of node-backup names it as
|
|
||||||
// `${backup:lines}` (what to run and which paths to keep) and `${backup:data}` (every item with its class and
|
|
||||||
// protection): the text `${contribution:node-backup:backup}` and `${contribution:node-backup:data}` render, from the
|
|
||||||
// same function, derived from each placed module's declared data (novox/hq ADR 0233). A handed holder has the text in
|
|
||||||
// its plan's placement for its client to fill; one not handed has the controller fill it where it stands.
|
|
||||||
|
|
||||||
// BackupNamespace is the placeholder kind of the machine's backup lines and data.
|
// BackupNamespace is the placeholder kind of the machine's backup lines and data.
|
||||||
const BackupNamespace = "backup"
|
const BackupNamespace = "backup"
|
||||||
|
|
||||||
// ofBackup is where a holder places them: ${backup:<key>}. Loose inside the braces, so a misspelt key is found and
|
|
||||||
// refused rather than written out as text.
|
|
||||||
var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
|
|
||||||
|
|
||||||
// backupKinds is the contribution kind each key of the backup placement renders.
|
|
||||||
var backupKinds = map[string]string{"lines": BackupKindBackup, "data": BackupKindData}
|
|
||||||
|
|
||||||
// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
|
|
||||||
type backupPlacer struct {
|
|
||||||
modules []Manifest
|
|
||||||
with Rendering
|
|
||||||
facts map[string]string
|
|
||||||
caps map[string]bool
|
|
||||||
unplaced *[]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// placed is the text of one key for a holder, or what is wrong with naming it.
|
|
||||||
func (b backupPlacer) placed(holder Manifest, key string, item bool) (string, string) {
|
|
||||||
kind, known := backupKinds[key]
|
|
||||||
switch {
|
|
||||||
case item:
|
|
||||||
return "", "an item is kept per item; the machine's backup lines are placed only in a file"
|
|
||||||
case !known:
|
|
||||||
return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key)
|
|
||||||
case !holder.ClaimsSeat(BackupSeat):
|
|
||||||
return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+
|
|
||||||
"(novox/hq ADR 0212)", holder.Module, BackupSeat)
|
|
||||||
}
|
|
||||||
text, left, err := seatContributions(b.modules, holder, BackupSeat+":"+kind, b.with, b.facts, b.caps)
|
|
||||||
if b.unplaced != nil {
|
|
||||||
*b.unplaced = append(*b.unplaced, left...)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", err.Error()
|
|
||||||
}
|
|
||||||
return text, ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,126 +1,10 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A machine's backup lines are placement (design 57 L3, issue 794): the holder of node-backup names them as
|
// A machine's backup lines are placement (design 57 L3, issue 794): the holder of node-backup names them as
|
||||||
// `${backup:lines}` and `${backup:data}`, which a handed holder has its client fill from the plan and an unhanded
|
// `${backup:lines}` and `${backup:data}`, which a handed holder has its client fill from the plan and an unhanded
|
||||||
// one has the controller fill as it fills `${contribution:node-backup:backup}` and `${...:data}`: the same text.
|
// one has the controller fill as it fills `${contribution:node-backup:backup}` and `${...:data}`: the same text.
|
||||||
|
|
||||||
const (
|
|
||||||
oldBackupText = "# lines\n${contribution:node-backup:backup}"
|
|
||||||
oldDataText = "# data\n${contribution:node-backup:data}"
|
|
||||||
placedBackupTxt = "# lines\n${backup:lines}"
|
|
||||||
placedDataTxt = "# data\n${backup:data}"
|
|
||||||
backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}],
|
|
||||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
|
||||||
"resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"},
|
|
||||||
{"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}`
|
|
||||||
)
|
|
||||||
|
|
||||||
func dataModule(name string) string {
|
func dataModule(name string) string {
|
||||||
return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
|
return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
|
||||||
"data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}`
|
"data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// backupsOn is a machine of a holder whose files say these texts and the modules that declare data.
|
|
||||||
func backupsOn(t *testing.T, lines, data string, modules ...string) Resolution {
|
|
||||||
t.Helper()
|
|
||||||
all := []Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))}
|
|
||||||
names := []string{"restic"}
|
|
||||||
for _, name := range modules {
|
|
||||||
all = append(all, mustParse(t, dataModule(name)))
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
r, err := Resolve(shelf(all...), names, workstation(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
r.Account = "op"
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
var backupModules = []string{"alpha", "beta", "gamma"}
|
|
||||||
|
|
||||||
// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort.
|
|
||||||
func gammaLeftOut(with Rendering) Rendering {
|
|
||||||
with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"}
|
|
||||||
return with
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) {
|
|
||||||
// The old form is an unmoved holder: its placeholders are the controller's alone.
|
|
||||||
oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1)
|
|
||||||
all := []Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))}
|
|
||||||
for _, name := range backupModules {
|
|
||||||
all = append(all, mustParse(t, dataModule(name)))
|
|
||||||
}
|
|
||||||
old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
old.Account = "op"
|
|
||||||
wantLines, wantData := fileOf(t, old, gammaLeftOut(Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(Rendering{}), "restic.data-declared")
|
|
||||||
if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") {
|
|
||||||
t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData)
|
|
||||||
}
|
|
||||||
moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...)
|
|
||||||
// Not handed: the controller fills the new placeholders with the old text.
|
|
||||||
if got := fileOf(t, moved, gammaLeftOut(Rendering{}), "restic.declared"); got != wantLines {
|
|
||||||
t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines)
|
|
||||||
}
|
|
||||||
if got := fileOf(t, moved, gammaLeftOut(Rendering{}), "restic.data-declared"); got != wantData {
|
|
||||||
t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData)
|
|
||||||
}
|
|
||||||
// Handed: the plan carries the same text as placement, and the files are the client's.
|
|
||||||
c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000")))
|
|
||||||
p := c.Plans["restic"].Placement
|
|
||||||
if got := "# lines\n" + p["backup:lines"]; got != wantLines {
|
|
||||||
t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines)
|
|
||||||
}
|
|
||||||
if got := "# data\n" + p["backup:data"]; got != wantData {
|
|
||||||
t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData)
|
|
||||||
}
|
|
||||||
if len(p) != 2 {
|
|
||||||
t.Errorf("the plan's placement is %v", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAModuleAddedOrTakenAwayChangesTheBackupPlan(t *testing.T) {
|
|
||||||
plan := func(modules ...string) map[string]string {
|
|
||||||
c, _ := composeWith(t, backupsOn(t, placedBackupTxt, placedDataTxt, modules...), handingTo("store:5000"))
|
|
||||||
return c.Plans["restic"].Placement
|
|
||||||
}
|
|
||||||
two, three := plan("alpha", "beta"), plan("alpha", "beta", "gamma")
|
|
||||||
if reflect.DeepEqual(two, three) || strings.Contains(two["backup:lines"], "gamma") || !strings.Contains(three["backup:lines"], "# gamma\n") {
|
|
||||||
t.Errorf("two modules give %v, three give %v", two, three)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnUnknownBackupKeyIsRefused(t *testing.T) {
|
|
||||||
r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...)
|
|
||||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") {
|
|
||||||
t.Errorf("handed, an unknown key is %v", err)
|
|
||||||
}
|
|
||||||
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") {
|
|
||||||
t.Errorf("not handed, an unknown key is %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileOf(t *testing.T, r Resolution, with Rendering, id string) string {
|
|
||||||
t.Helper()
|
|
||||||
out, err := r.Declaration(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, res := range out {
|
|
||||||
if res["id"] == id {
|
|
||||||
return res["content"].(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatalf("%s was not composed", id)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A backup contribution written by hand still names its module's own directories; one it does not
|
|
||||||
// declare is refused where it is written rather than reaching the holder as the literal text. (The
|
|
||||||
// catalogue check refuses a hand-written backup line at all since ADR 0233; parsing still reads one,
|
|
||||||
// because a module on the shelf was written before.)
|
|
||||||
func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"pg","version":"1",
|
|
||||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:nowhere}"}]}`))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "nowhere") {
|
|
||||||
t.Fatalf("a backup of an undeclared directory was accepted: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no
|
|
||||||
// data, is still backed up: its lines are placed as written, each module's under a comment naming it.
|
|
||||||
func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) {
|
|
||||||
pg, err := ParseManifest([]byte(`{"module":"pg","version":"1",
|
|
||||||
"resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}],
|
|
||||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
mail, err := ParseManifest([]byte(`{"module":"mail","version":"1",
|
|
||||||
"resources":[{"id":"spool","type":"directory","mode":"0700"}],
|
|
||||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
placed, _, err := seatContributions([]Manifest{pg, mail}, Manifest{}, BackupSeat+":"+"backup", Rendering{}, nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, want := range []string{"# pg\nrun pg-dump-all\npath /srv/pg/dumps\n", "# mail\npath /var/lib/mail/spool\n"} {
|
|
||||||
if !strings.Contains(placed, want) {
|
|
||||||
t.Errorf("placed contributions lack %q:\n%s", want, placed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(placed, "${dir:") {
|
|
||||||
t.Errorf("a directory reached the holder unfilled:\n%s", placed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,12 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The module that provides the artifact store may not be delivered through it.
|
|
||||||
//
|
|
||||||
// Building publishes to the store and the builder will not start without one, so a module that
|
|
||||||
// provides the store and also builds something asks the mesh to put an artifact into the thing
|
|
||||||
// that artifact is needed to create. On a mesh new enough to have no registry, that is a build
|
|
||||||
// that never returns (novox/hq 04-ISSUES/029).
|
|
||||||
func TestTheArtifactStoreCannotBeDeliveredThroughItself(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"registry","version":"1",` +
|
|
||||||
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
|
||||||
`"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"registry:2"}]},` +
|
|
||||||
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
|
|
||||||
`"artifact":"registry","ports":["5000:5000"]}]}`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a registry module that builds its own image was accepted; the build has " +
|
|
||||||
"nowhere to publish until the module it belongs to is already running")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "artifact-store") {
|
|
||||||
t.Fatalf("refused without naming the provision the cycle turns on: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Naming the image directly is the way out, and must stay accepted.
|
// Naming the image directly is the way out, and must stay accepted.
|
||||||
func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) {
|
func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) {
|
||||||
_, err := ParseManifest([]byte(`{"module":"registry","version":"1",` +
|
_, err := decodeForTest([]byte(`{"module":"registry","version":"1",` +
|
||||||
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||||
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
|
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
|
||||||
`"image":"registry@sha256:` +
|
`"image":"registry@sha256:` +
|
||||||
@@ -41,7 +19,7 @@ func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) {
|
|||||||
|
|
||||||
// And an ordinary module still builds whatever it likes.
|
// And an ordinary module still builds whatever it likes.
|
||||||
func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) {
|
func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) {
|
||||||
_, err := ParseManifest([]byte(`{"module":"forge","version":"1",` +
|
_, err := decodeForTest([]byte(`{"module":"forge","version":"1",` +
|
||||||
`"build":{"artifacts":[{"name":"forge","kind":"upstream","from":"gitea/gitea:1.22"}]},` +
|
`"build":{"artifacts":[{"name":"forge","kind":"upstream","from":"gitea/gitea:1.22"}]},` +
|
||||||
`"resources":[{"id":"run","type":"container","name":"forge","artifact":"forge"}]}`))
|
`"resources":[{"id":"run","type":"container","name":"forge","artifact":"forge"}]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,25 +2,9 @@ package catalogue
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A consumer of a provision that keeps its data stays bound where its data is (novox/hq ADR 0232).
|
|
||||||
//
|
|
||||||
// **What a resolution chooses is not where a consumer's data is.** Resolving answers "which provider
|
|
||||||
// would I pick now", from the seats' holders, the pins and what is assigned where, and every one of
|
|
||||||
// those can change under a consumer without anybody meaning to move it. For a resolver that is the
|
|
||||||
// point: any provider answers alike. For a database it is the consumer's whole state: on 2026-10-05
|
|
||||||
// one change to how a seat's holder answers (issue 258) re-bound five database consumers on one
|
|
||||||
// machine to the store on another, each was given a fresh, empty database there, and nothing warned
|
|
||||||
// for twenty hours (issue 273). Nothing was lost only because the old provider kept everything.
|
|
||||||
//
|
|
||||||
// So the mesh records where each such consumer was last sent (the store's `binding` table), and a
|
|
||||||
// resolution that would answer it from anywhere else keeps the recorded provider instead and says so.
|
|
||||||
// **Only a pin moves it**, because a pin is a person: the one act that says "answer this machine's
|
|
||||||
// consumers from there", taken knowing the data must go first.
|
|
||||||
|
|
||||||
// KeptBinding is one consumer this resolution would have moved, and did not.
|
// KeptBinding is one consumer this resolution would have moved, and did not.
|
||||||
type KeptBinding struct {
|
type KeptBinding struct {
|
||||||
// Machine is where the consumer runs, and Consumer the module there that is bound.
|
// Machine is where the consumer runs, and Consumer the module there that is bound.
|
||||||
@@ -40,99 +24,6 @@ func (k KeptBinding) String() string {
|
|||||||
k.Consumer, k.Provision, k.Bound, k.Would, k.Bound, k.Machine, k.Provision, k.Would.Node, k.Would.Module)
|
k.Consumer, k.Provision, k.Bound, k.Would, k.Bound, k.Machine, k.Provision, k.Would.Node, k.Would.Module)
|
||||||
}
|
}
|
||||||
|
|
||||||
// keepBound holds every need for a provision that keeps its consumers' data at the provider its
|
|
||||||
// consumer was bound to, where the resolution chose another.
|
|
||||||
//
|
|
||||||
// A need with no record is a binding being made, and is left as resolved: it is recorded when it is
|
|
||||||
// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too.
|
|
||||||
// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer,
|
|
||||||
// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused,
|
|
||||||
// never answered by the provider chosen**: answering it there is exactly the silent move this exists
|
|
||||||
// to stop, and the consumer's data is still wherever it was.
|
|
||||||
func keepBound(needs []Needed, catalogue map[string]Manifest, node Node, world World,
|
|
||||||
keeps map[string]bool, here func(string) bool) ([]Needed, []KeptBinding, []string) {
|
|
||||||
var kept []KeptBinding
|
|
||||||
var problems []string
|
|
||||||
refused := map[[2]string]bool{}
|
|
||||||
out := make([]Needed, 0, len(needs))
|
|
||||||
for _, n := range needs {
|
|
||||||
if n.ByRecord || !keeps[n.Name] {
|
|
||||||
out = append(out, n)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n.KeepsData = true
|
|
||||||
bound, recorded := world.Bound[n.For][n.Name]
|
|
||||||
chose := Chosen{Node: n.From, Module: n.Module}
|
|
||||||
if !recorded || sameProvider(bound, chose) {
|
|
||||||
out = append(out, n)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if pin, pinned := world.Pinned[n.Name]; pinned && pin.matches(Provider{Node: chose.Node, Module: chose.Module}) {
|
|
||||||
out = append(out, n)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
held, ok, why := boundNeed(n, bound, catalogue, node, world, here)
|
|
||||||
if !ok {
|
|
||||||
if key := [2]string{n.For, n.Name}; !refused[key] {
|
|
||||||
refused[key] = true
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+
|
|
||||||
"which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+
|
|
||||||
"%s back what it provided",
|
|
||||||
n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module,
|
|
||||||
bound.Node))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, held)
|
|
||||||
kept = append(kept, KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose})
|
|
||||||
}
|
|
||||||
return out, kept, problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none
|
|
||||||
// is written without one, but a person's hand might) matches any module on its node.
|
|
||||||
func sameProvider(bound, chose Chosen) bool {
|
|
||||||
return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module)
|
|
||||||
}
|
|
||||||
|
|
||||||
// boundNeed is the need answered by the recorded provider, or why it cannot be.
|
|
||||||
func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node, world World,
|
|
||||||
here func(string) bool) (Needed, bool, string) {
|
|
||||||
held := Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true}
|
|
||||||
if bound.Node == node.Name {
|
|
||||||
m, known := catalogue[bound.Module]
|
|
||||||
if !known || !here(bound.Module) || !providesAt(m, n.Name, ScopeMesh) {
|
|
||||||
return Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name)
|
|
||||||
}
|
|
||||||
at := node.At
|
|
||||||
if at == "" {
|
|
||||||
at = "127.0.0.1"
|
|
||||||
}
|
|
||||||
held.From, held.At, held.Module = node.Name, at, m.Module
|
|
||||||
held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
|
|
||||||
held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name)
|
|
||||||
return held, true, ""
|
|
||||||
}
|
|
||||||
matching := bound.among(world.Offered[n.Name])
|
|
||||||
if len(matching) != 1 {
|
|
||||||
return Needed{}, false, fmt.Sprintf("%s no longer provides it", bound)
|
|
||||||
}
|
|
||||||
p := matching[0]
|
|
||||||
if node.At == "" || p.At == "" {
|
|
||||||
return Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node)
|
|
||||||
}
|
|
||||||
identity := DefaultIdentityBound
|
|
||||||
if pm, known := catalogue[p.Module]; known {
|
|
||||||
held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
|
|
||||||
held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name)
|
|
||||||
identity = pm.IdentityBoundOf(n.Name)
|
|
||||||
}
|
|
||||||
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
|
|
||||||
held.LeftOut = p.LeftOut
|
|
||||||
return held, true, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unbound is one consumer that still asks for a provision and whose own resolution binds it to
|
// Unbound is one consumer that still asks for a provision and whose own resolution binds it to
|
||||||
// another provider than the one a pair credential on record was made with (novox/hq issue 274).
|
// another provider than the one a pair credential on record was made with (novox/hq issue 274).
|
||||||
//
|
//
|
||||||
@@ -168,19 +59,3 @@ func (u Unbound) String() string {
|
|||||||
"login and keeps its data until `cleanup delete` (ADR 0230); the credential from %s stays on "+
|
"login and keeps its data until `cleanup delete` (ADR 0230); the credential from %s stays on "+
|
||||||
"record while that login does", who, u.Consumer, now, u.Provision, u.Provider, u.Provider, u.Provider)
|
"record while that login does", who, u.Consumer, now, u.Provision, u.Provider, u.Provider, u.Provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the
|
|
||||||
// module that requires it and the credential's local name — answered by a machine rather than by a
|
|
||||||
// record. None means this machine states no such binding.
|
|
||||||
func (r Resolution) BindsFrom(provision, module, local string) []string {
|
|
||||||
var from []string
|
|
||||||
for _, n := range r.Needs {
|
|
||||||
if n.ByRecord || n.Name != provision || n.For != module || n.Local != local {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !slices.Contains(from, n.From) {
|
|
||||||
from = append(from, n.From)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return from
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,189 +1,5 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The half of a connection that is not secret, put where the program reading it can find it.
|
|
||||||
//
|
|
||||||
// **The asymmetry this removes was backwards** (novox/hq 04-ISSUES/023). A sealed credential can
|
|
||||||
// be placed inside any configuration file a module writes: the module leaves a hole, the mesh
|
|
||||||
// delivers the value sealed beside it, and the host — the only thing that sees both — fills it in.
|
|
||||||
// The host and the port and the name to present are ordinary facts the mesh holds in the clear,
|
|
||||||
// and they were the ones stuck: readable only inside a JSON binding, which a program reading
|
|
||||||
// `KEY=value` cannot use.
|
|
||||||
//
|
|
||||||
// So the same shape, and simpler. These values are not secret, so **the control plane substitutes
|
|
||||||
// them itself** before the declaration is sent. Nothing new reaches the host, which learns no
|
|
||||||
// formats and gains no fields.
|
|
||||||
//
|
|
||||||
// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is:
|
|
||||||
// `at`, `as`, `from` and `address` (the providing machine's private address, novox/hq ADR 0194) are
|
|
||||||
// facts about any provision at all, and everything else comes from what the provider said it
|
|
||||||
// serves — whose keys are agreed by the requirement's name, not by this file.
|
|
||||||
|
|
||||||
// bound is where a module says a value from one of its bindings belongs:
|
|
||||||
// ${bound:<provision>:<key>}, and for an optional requirement the value it takes while absent,
|
|
||||||
// ${bound:<provision>:<key>|default:<value>} (novox/hq ADR 0330 §4). A default is never empty: an
|
|
||||||
// absent requirement is never rendered as an empty string.
|
|
||||||
var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`)
|
|
||||||
|
|
||||||
// knownFor is everything a module may name from one of its bindings.
|
|
||||||
//
|
|
||||||
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
|
||||||
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
|
||||||
// reason.
|
|
||||||
func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) {
|
|
||||||
out := map[string]map[string]string{}
|
|
||||||
for _, want := range m.Wants() {
|
|
||||||
for i := range needs {
|
|
||||||
n := needs[i]
|
|
||||||
if n.Name != want || n.For != m.Module {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
|
||||||
values := map[string]string{
|
|
||||||
"at": n.At,
|
|
||||||
"from": n.From,
|
|
||||||
"as": as,
|
|
||||||
}
|
|
||||||
// What the provider derives for this consumer rather than for all of them
|
|
||||||
// (novox/hq ADR 0202). Filled here, the one place a provision and the module
|
|
||||||
// requiring it are both in hand.
|
|
||||||
served, err := ServedTo(n.Serves, as)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
|
|
||||||
}
|
|
||||||
for key, value := range served {
|
|
||||||
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
|
||||||
// which is what a float would write and what a connection string would refuse.
|
|
||||||
values[key] = plainly(value)
|
|
||||||
}
|
|
||||||
out[want] = values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
|
||||||
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
|
|
||||||
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
|
|
||||||
// what the module is called is the mesh's statement, not the provider's.
|
|
||||||
func withOwnNames(values map[string]string, own map[string]any) {
|
|
||||||
for _, key := range []string{"name", "internal-name"} {
|
|
||||||
if v, ok := own[key].(string); ok {
|
|
||||||
values[key] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
many, _ := own["names"].(map[string]any)
|
|
||||||
for local, raw := range many {
|
|
||||||
names, _ := raw.(map[string]any)
|
|
||||||
for _, key := range []string{"name", "internal-name"} {
|
|
||||||
if v, ok := names[key].(string); ok {
|
|
||||||
values[key+"-"+local] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// plainly renders a served value as a program would expect to read it.
|
|
||||||
func plainly(value any) string {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case string:
|
|
||||||
return v
|
|
||||||
case float64:
|
|
||||||
if v == float64(int64(v)) {
|
|
||||||
return fmt.Sprintf("%d", int64(v))
|
|
||||||
}
|
|
||||||
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".")
|
|
||||||
case bool:
|
|
||||||
return fmt.Sprintf("%t", v)
|
|
||||||
case nil:
|
|
||||||
return ""
|
|
||||||
default:
|
|
||||||
return fmt.Sprint(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows.
|
|
||||||
//
|
|
||||||
// A placeholder naming something the module does not require, or a key the provider does not
|
|
||||||
// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a
|
|
||||||
// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing
|
|
||||||
// somewhere that names neither the module nor the mesh.
|
|
||||||
//
|
|
||||||
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
|
|
||||||
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
|
|
||||||
// that already; this is the same rule where the file is made, for a manifest registered before it.
|
|
||||||
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
|
|
||||||
if fmt.Sprint(resource["type"]) != "file" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content, ok := resource["content"].(string)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var refused error
|
|
||||||
filled := bound.ReplaceAllStringFunc(content, func(token string) string {
|
|
||||||
if refused != nil {
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
m := bound.FindStringSubmatch(token)
|
|
||||||
provision, key, fallback := m[1], m[2], m[3]
|
|
||||||
values, has := known[provision]
|
|
||||||
if !has {
|
|
||||||
if absent[provision] {
|
|
||||||
if fallback != "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
refused = fmt.Errorf(
|
|
||||||
"%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+
|
|
||||||
"value for then (${bound:%s:%s|default:<value>}), so it would be written empty "+
|
|
||||||
"(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key)
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
refused = fmt.Errorf(
|
|
||||||
"%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s",
|
|
||||||
module, provision, key, module, provision, orNothing(namesOfBindings(known)))
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
value, said := values[key]
|
|
||||||
if !said {
|
|
||||||
refused = fmt.Errorf(
|
|
||||||
"%s asks its %s binding for %q, and what answers it says %s",
|
|
||||||
module, provision, key, orNothing(namesOfKeys(values)))
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
if refused != nil {
|
|
||||||
return refused
|
|
||||||
}
|
|
||||||
resource["content"] = filled
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesOfBindings(known map[string]map[string]string) []string {
|
|
||||||
var names []string
|
|
||||||
for name := range known {
|
|
||||||
names = append(names, fmt.Sprintf("%q", name))
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesOfKeys(values map[string]string) []string {
|
|
||||||
var names []string
|
|
||||||
for key := range values {
|
|
||||||
names = append(names, fmt.Sprintf("%q", key))
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
func orNothing(names []string) string {
|
func orNothing(names []string) string {
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
return "nothing"
|
return "nothing"
|
||||||
|
|||||||
@@ -1,217 +1,10 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A consumer writes its own connection string (novox/hq 04-ISSUES/023).
|
|
||||||
//
|
|
||||||
// Everything needed for one now reaches the file that needs it: the address and port from what the
|
|
||||||
// provider serves, the name to present from what the mesh decided, the database name from what the
|
|
||||||
// module itself contributed, and the password through the sealed hole the host fills.
|
|
||||||
func TestAConsumerCanWriteAConnectionString(t *testing.T) {
|
|
||||||
r := Resolution{
|
|
||||||
Node: "workstation",
|
|
||||||
Modules: []Manifest{{
|
|
||||||
Module: "keycloak",
|
|
||||||
Requires: []string{"postgres-database"},
|
|
||||||
Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env",
|
|
||||||
"mode": "0600",
|
|
||||||
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" +
|
|
||||||
"${bound:postgres-database:port}/keycloak\n" +
|
|
||||||
"KC_DB_USERNAME=${bound:postgres-database:as}\n" +
|
|
||||||
"KC_DB_PASSWORD=${secret:postgres-database}\n",
|
|
||||||
}},
|
|
||||||
}},
|
|
||||||
Needs: []Needed{{
|
|
||||||
Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak",
|
|
||||||
Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
out, err := r.Declaration(Rendering{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
file := fileNamed(out, "keycloak.dbenv")
|
|
||||||
if file == nil {
|
|
||||||
t.Fatalf("no file in %v", out)
|
|
||||||
}
|
|
||||||
got := file["content"].(string)
|
|
||||||
|
|
||||||
want := "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak\n" +
|
|
||||||
"KC_DB_USERNAME=mesh_workstation_keycloak\n" +
|
|
||||||
"KC_DB_PASSWORD=${secret:postgres-database}\n"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("the consumer cannot connect with what it was given:\n got %q\nwant %q", got, want)
|
|
||||||
}
|
|
||||||
// The password is still a hole. Only the host may fill that one, and only on the machine.
|
|
||||||
if sealed, _ := file["secrets"].(map[string]any); sealed["postgres-database"] != "sealed-db" {
|
|
||||||
t.Errorf("the credential did not travel with the file: %v", file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A provider answered on its consumer's own machine is announced at the port the machine PUBLISHES
|
|
||||||
// it on, not the port the module declared (novox/hq 04-ISSUES/038).
|
|
||||||
//
|
|
||||||
// Issue 018 made the same-node provider announced at all; this is the promise after it — that the
|
|
||||||
// port a co-located consumer is told is the one actually listening at the address it is handed. The
|
|
||||||
// mesh assigns a bare `ports` mapping a host port (here the container's 5432 becomes 15432 on this
|
|
||||||
// machine), and it must announce 15432, or the consumer dials workstation.internal:5432 where the
|
|
||||||
// provider is not published. Cross-node consumers already got this (the plan re-derives the served
|
|
||||||
// facts with the provider's assignment); the same-node need was settled before the assignment and
|
|
||||||
// carried the declared port until now.
|
|
||||||
func TestASameNodeProviderIsAnnouncedAtThePortItIsPublishedOn(t *testing.T) {
|
|
||||||
provider := Manifest{
|
|
||||||
Module: "postgres", Version: "1",
|
|
||||||
Provides: FromAnywhere("postgres-database"),
|
|
||||||
Listens: []Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}},
|
|
||||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "server", "type": "container", "name": "postgres",
|
|
||||||
"ports": []any{"5432"},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
consumer := Manifest{
|
|
||||||
Module: "keycloak", Version: "1", Requires: []string{"postgres-database"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", "mode": "0600",
|
|
||||||
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" +
|
|
||||||
"${bound:postgres-database:port}/keycloak\n",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
r, err := Resolve(shelf(provider, consumer),
|
|
||||||
[]string{"postgres", "keycloak"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// The mesh assigned the container's 5432 to 15432 on this machine.
|
|
||||||
out, err := r.Declaration(Rendering{Ports: map[string]map[int]int{"postgres": {5432: 15432}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where the provider is actually published: the assigned host port, on the machine at large.
|
|
||||||
server := fileNamed(out, "postgres.server")
|
|
||||||
if server == nil {
|
|
||||||
t.Fatalf("the provider container is not in the declaration: %v", out)
|
|
||||||
}
|
|
||||||
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "15432:5432") {
|
|
||||||
t.Fatalf("the provider was not published on the assigned port: %v", server["ports"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the consumer was told to dial: the SAME port, at the announced address — not the 5432 the
|
|
||||||
// module declared, which is closed on that address.
|
|
||||||
dbenv := fileNamed(out, "keycloak.dbenv")
|
|
||||||
if dbenv == nil {
|
|
||||||
t.Fatalf("the consumer's binding file is not in the declaration: %v", out)
|
|
||||||
}
|
|
||||||
got := dbenv["content"].(string)
|
|
||||||
want := "KC_DB_URL=jdbc:postgresql://workstation.internal:15432/keycloak\n"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("the consumer dials a port the provider does not listen on:\n got %q\nwant %q",
|
|
||||||
got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A port is 5432, not 5432.000000 — which is what a number decoded from JSON would write, and what
|
|
||||||
// every connection string in the world would refuse.
|
|
||||||
func TestAPortIsWrittenAsAPort(t *testing.T) {
|
|
||||||
if got := plainly(float64(5432)); got != "5432" {
|
|
||||||
t.Errorf("a port was rendered as %q", got)
|
|
||||||
}
|
|
||||||
if got := plainly("postgres"); got != "postgres" {
|
|
||||||
t.Errorf("a string was rendered as %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The name the provider will create is the name the consumer is told to present. One derivation,
|
|
||||||
// so the two agree by construction rather than by two conventions written on the same day.
|
|
||||||
func TestTheProviderAndTheConsumerAgreeOnTheName(t *testing.T) {
|
|
||||||
provider, err := Resolve(shelf(Manifest{
|
|
||||||
Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database"),
|
|
||||||
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
|
|
||||||
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
|
|
||||||
}), []string{"postgres"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := provider.Declaration(Rendering{Grants: []Grant{{
|
|
||||||
Provision: "postgres-database", Consumer: "workstation", From: "keycloak",
|
|
||||||
Values: map[string]any{"name": "keycloak"}, Sealed: "sealed",
|
|
||||||
}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var told string
|
|
||||||
for _, res := range out {
|
|
||||||
if res["path"] == "/var/lib/postgres/grants/mesh.json" {
|
|
||||||
told = res["content"].(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !strings.Contains(told, `"as": "mesh_workstation_keycloak"`) {
|
|
||||||
t.Errorf("the provider was not told what to call the login: %s", told)
|
|
||||||
}
|
|
||||||
if told == "" {
|
|
||||||
t.Fatal("the provider was told nothing at all")
|
|
||||||
}
|
|
||||||
// Which is exactly what the consumer, on its own machine, is told to present.
|
|
||||||
if ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" {
|
|
||||||
t.Errorf("got %q", ConsumerIdentity("workstation", "keycloak"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A binding the module does not have is a typo, refused where it was written.
|
|
||||||
func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) {
|
|
||||||
r := Resolution{
|
|
||||||
Node: "workstation",
|
|
||||||
Modules: []Manifest{{
|
|
||||||
Module: "keycloak", Requires: []string{"postgres-database"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "conf", "type": "file", "path": "/etc/keycloak.conf",
|
|
||||||
"content": "host=${bound:s3-bucket:at}\n",
|
|
||||||
}},
|
|
||||||
}},
|
|
||||||
Needs: []Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}},
|
|
||||||
}
|
|
||||||
_, err := r.Declaration(Rendering{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a module read a binding it does not have")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "postgres-database") {
|
|
||||||
t.Errorf("the refusal does not say what it could have named: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A key the provider does not serve is refused too. Written through, the literal placeholder
|
|
||||||
// becomes a hostname, and the failure names neither the module nor the mesh.
|
|
||||||
func TestAKeyTheProviderDoesNotServeIsRefused(t *testing.T) {
|
|
||||||
r := Resolution{
|
|
||||||
Node: "workstation",
|
|
||||||
Modules: []Manifest{{
|
|
||||||
Module: "keycloak", Requires: []string{"postgres-database"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "conf", "type": "file", "path": "/etc/keycloak.conf",
|
|
||||||
"content": "sock=${bound:postgres-database:socket}\n",
|
|
||||||
}},
|
|
||||||
}},
|
|
||||||
Needs: []Needed{{
|
|
||||||
Name: "postgres-database", From: "anchor", At: "a", For: "keycloak",
|
|
||||||
Serves: map[string]any{"port": float64(5432)},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
_, err := r.Declaration(Rendering{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a module read a key nothing serves, and the placeholder became a value")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), `"port"`) {
|
|
||||||
t.Errorf("the refusal does not say what is served: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A name too long to stay distinct is refused before anything is created.
|
// A name too long to stay distinct is refused before anything is created.
|
||||||
func TestAnIdentityTooLongToBeDistinctIsRefused(t *testing.T) {
|
func TestAnIdentityTooLongToBeDistinctIsRefused(t *testing.T) {
|
||||||
if err := CheckIdentity("anchor", "keycloak"); err != nil {
|
if err := CheckIdentity("anchor", "keycloak"); err != nil {
|
||||||
@@ -232,34 +25,3 @@ func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) {
|
|||||||
t.Fatal("one module on two machines shares an identity")
|
t.Fatal("one module on two machines shares an identity")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A machine's resolver configuration must name its resolver by address — it cannot resolve the name
|
|
||||||
// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing
|
|
||||||
// machine's private address beside its name, and only when the machine has one.
|
|
||||||
func TestABindingOffersTheProvidersAddress(t *testing.T) {
|
|
||||||
consumer := func() Resolution {
|
|
||||||
return Resolution{
|
|
||||||
Node: "workstation",
|
|
||||||
Modules: []Manifest{{
|
|
||||||
Module: "resolv-conf",
|
|
||||||
Requires: []string{"wildcard-resolution"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
|
|
||||||
"content": "nameserver ${bound:wildcard-resolution:address}\n",
|
|
||||||
}},
|
|
||||||
}},
|
|
||||||
Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out, err := consumer().Declaration(Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" {
|
|
||||||
t.Fatalf("the resolver is not named by its address: %q", got)
|
|
||||||
}
|
|
||||||
// A machine with no address yet: refused, never written with a blank where the address belongs.
|
|
||||||
if _, err := consumer().Declaration(Rendering{}); err == nil {
|
|
||||||
t.Fatal("a file naming an address the mesh does not have was composed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,200 +14,6 @@ import (
|
|||||||
// before this machine's own provider — re-bound all five to the holder, each was made a fresh, empty
|
// before this machine's own provider — re-bound all five to the holder, each was made a fresh, empty
|
||||||
// database there, and nothing said so.
|
// database there, and nothing said so.
|
||||||
|
|
||||||
var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"}
|
|
||||||
|
|
||||||
func storeMesh() map[string]Manifest {
|
|
||||||
shelf := map[string]Manifest{
|
|
||||||
"store": {Module: "store", Version: "1",
|
|
||||||
Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
|
||||||
Claims: []Claim{{Name: "mesh-store", Scope: ScopeMesh}},
|
|
||||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
|
|
||||||
Grants: map[string]string{"postgres-database": "/grants"}},
|
|
||||||
"resolver": {Module: "resolver", Version: "1",
|
|
||||||
Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}},
|
|
||||||
Claims: []Claim{{Name: "mesh-dns-resolver", Scope: ScopeMesh}}},
|
|
||||||
"network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}},
|
|
||||||
}
|
|
||||||
for _, c := range incidentConsumers {
|
|
||||||
shelf[c] = Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}}
|
|
||||||
}
|
|
||||||
return shelf
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeWorld is the rest of the mesh as the home server's plan sees it: the anchor runs a store and a
|
|
||||||
// resolver and holds both seats; the home server runs its own of each.
|
|
||||||
func storeWorld() World {
|
|
||||||
w := World{Offered: map[string][]Provider{
|
|
||||||
"postgres-database": {
|
|
||||||
{Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
|
|
||||||
{Node: "home", At: "home.internal", Module: "store", Serves: map[string]any{"port": 5434}},
|
|
||||||
},
|
|
||||||
"wildcard-resolution": {
|
|
||||||
{Node: "anchor", At: "anchor.internal", Module: "resolver"},
|
|
||||||
{Node: "home", At: "home.internal", Module: "resolver"},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
w.Held = []Held{
|
|
||||||
{Claim: "mesh-store", Scope: ScopeMesh, Node: "anchor", Module: "store"},
|
|
||||||
{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "anchor", Module: "resolver"},
|
|
||||||
}
|
|
||||||
w.Holdings = w.Held
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
var home = Node{Name: "home", At: "home.internal"}
|
|
||||||
|
|
||||||
func homeAssigned() []string {
|
|
||||||
return append([]string{"store", "resolver", "network"}, incidentConsumers...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func boundFrom(t *testing.T, r Resolution, consumer, provision string) Needed {
|
|
||||||
t.Helper()
|
|
||||||
for _, n := range r.Needs {
|
|
||||||
if n.For == consumer && n.Name == provision {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatalf("no binding of %s for %s: %+v", consumer, provision, r.Needs)
|
|
||||||
return Needed{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheIncidentAMachinesOwnStoreKeepsItsConsumersWhenTheSeatIsHeldElsewhere(t *testing.T) {
|
|
||||||
got, err := Resolve(storeMesh(), homeAssigned(), home, storeWorld())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, c := range incidentConsumers {
|
|
||||||
if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" || n.Module != "store" {
|
|
||||||
t.Errorf("%s bound to %s/%s; its data is on the store beside it (issue 273)", c, n.From, n.Module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// And the resolver, which keeps nothing of anybody's, still answers from the seat's holder (258).
|
|
||||||
if n := boundFrom(t, got, "network", "wildcard-resolution"); n.From != "anchor" {
|
|
||||||
t.Errorf("the resolver bound to %s; the seat is held on anchor (issue 258)", n.From)
|
|
||||||
}
|
|
||||||
if len(got.Kept) != 0 {
|
|
||||||
t.Errorf("nothing was moved, and %d kept: %+v", len(got.Kept), got.Kept)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheIncidentWithEveryConsumerOnRecordStillMovesNothing(t *testing.T) {
|
|
||||||
w := storeWorld()
|
|
||||||
w.Bound = map[string]map[string]Chosen{}
|
|
||||||
for _, c := range incidentConsumers {
|
|
||||||
w.Bound[c] = map[string]Chosen{"postgres-database": {Node: "home", Module: "store"}}
|
|
||||||
}
|
|
||||||
got, err := Resolve(storeMesh(), homeAssigned(), home, w)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, c := range incidentConsumers {
|
|
||||||
if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" {
|
|
||||||
t.Errorf("%s bound to %s", c, n.From)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(got.Kept) != 0 {
|
|
||||||
t.Errorf("kept %+v", got.Kept)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPinElsewhereStillMovesAStoresConsumers(t *testing.T) {
|
|
||||||
w := storeWorld()
|
|
||||||
w.Pinned = map[string]Chosen{"postgres-database": {Node: "anchor", Module: "store"}}
|
|
||||||
w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
|
|
||||||
got, err := Resolve(storeMesh(), homeAssigned(), home, w)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" || n.Module != "store" {
|
|
||||||
t.Errorf("bound to %s/%s; a person pinned it to anchor", n.From, n.Module)
|
|
||||||
}
|
|
||||||
if len(got.Kept) != 0 {
|
|
||||||
t.Errorf("a pin is a person's move, not one to keep: %+v", got.Kept)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A consumer on a machine with no store of its own, bound to one store, when the seat moves to
|
|
||||||
// another: the holder would answer, and the binding stays.
|
|
||||||
func laptopWorld(holder string) World {
|
|
||||||
w := storeWorld()
|
|
||||||
w.Held = []Held{{Claim: "mesh-store", Scope: ScopeMesh, Node: holder, Module: "store"}}
|
|
||||||
w.Holdings = w.Held
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
var laptop = Node{Name: "laptop", At: "laptop.internal"}
|
|
||||||
|
|
||||||
func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) {
|
|
||||||
w := laptopWorld("home")
|
|
||||||
w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}}
|
|
||||||
got, err := Resolve(storeMesh(), []string{"board"}, laptop, w)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
n := boundFrom(t, got, "board", "postgres-database")
|
|
||||||
if n.From != "anchor" || n.At != "anchor.internal" || n.Serves["port"] != 5432 {
|
|
||||||
t.Fatalf("bound to %s at %s %v; its data is on anchor", n.From, n.At, n.Serves)
|
|
||||||
}
|
|
||||||
if len(got.Kept) != 1 {
|
|
||||||
t.Fatalf("the move was not said: %+v", got.Kept)
|
|
||||||
}
|
|
||||||
k := got.Kept[0]
|
|
||||||
if k.Bound != (Chosen{"anchor", "store"}) || k.Would != (Chosen{"home", "store"}) || k.Consumer != "board" {
|
|
||||||
t.Fatalf("kept %+v", k)
|
|
||||||
}
|
|
||||||
for _, want := range []string{"would move board's postgres-database from anchor/store to home/store",
|
|
||||||
"its data is on anchor/store", "pin laptop postgres-database home store", "move the data first"} {
|
|
||||||
if !strings.Contains(k.String(), want) {
|
|
||||||
t.Errorf("%q does not say %q", k.String(), want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Without a record it is a binding being made, and the holder answers it as before.
|
|
||||||
w.Bound = nil
|
|
||||||
got, err = Resolve(storeMesh(), []string{"board"}, laptop, w)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "home" {
|
|
||||||
t.Errorf("a new consumer bound to %s; the seat is held on home", n.From)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestABoundConsumerWhoseProviderIsGoneIsRefusedNotMoved(t *testing.T) {
|
|
||||||
w := laptopWorld("anchor")
|
|
||||||
w.Offered["postgres-database"] = w.Offered["postgres-database"][:1] // only anchor's store is left
|
|
||||||
w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
|
|
||||||
_, err := Resolve(storeMesh(), []string{"board"}, laptop, w)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("bound to home's store, which is gone, and answered by anchor's — the silent move")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"board on laptop is bound to home/store", "home/store no longer provides it",
|
|
||||||
"pin laptop postgres-database anchor store"} {
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("%q does not say %q", err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAMachinesOwnStoreUnassignedRefusesItsBoundConsumers(t *testing.T) {
|
|
||||||
w := storeWorld()
|
|
||||||
w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
|
|
||||||
_, err := Resolve(storeMesh(), []string{"board"}, home, w)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "store no longer runs on home") {
|
|
||||||
t.Fatalf("got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The first pass asks only what a machine offers, and is never refused by a binding.
|
|
||||||
func TestTheFirstPassKeepsNoBinding(t *testing.T) {
|
|
||||||
w := storeWorld()
|
|
||||||
w.Unchecked = true
|
|
||||||
w.Bound = map[string]map[string]Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}}
|
|
||||||
if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnOfferSaysWhetherItKeepsConsumerData(t *testing.T) {
|
func TestAnOfferSaysWhetherItKeepsConsumerData(t *testing.T) {
|
||||||
var o Offer
|
var o Offer
|
||||||
if err := json.Unmarshal([]byte(`{"name":"wildcard-resolution","scope":"mesh","keeps-consumer-data":false}`), &o); err != nil {
|
if err := json.Unmarshal([]byte(`{"name":"wildcard-resolution","scope":"mesh","keeps-consumer-data":false}`), &o); err != nil {
|
||||||
@@ -238,20 +44,3 @@ func TestAnOfferSaysWhetherItKeepsConsumerData(t *testing.T) {
|
|||||||
t.Error("a name any provider says keeps data keeps data")
|
t.Error("a name any provider says keeps data keeps data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it
|
|
||||||
// grants a credential.
|
|
||||||
func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) {
|
|
||||||
shelf := storeMesh()
|
|
||||||
no := false
|
|
||||||
s := shelf["store"]
|
|
||||||
s.Provides = []Offer{{Name: "postgres-database", Scope: ScopeMesh, KeepsConsumerData: &no}}
|
|
||||||
shelf["store"] = s
|
|
||||||
got, err := Resolve(shelf, homeAssigned(), home, storeWorld())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" {
|
|
||||||
t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,51 +1,9 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The mesh's bus is one per mesh, read from the catalogue beside this checkout.
|
|
||||||
//
|
|
||||||
// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116):
|
|
||||||
// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running,
|
|
||||||
// and the property that matters is that a second one anywhere is refused *when it is assigned*,
|
|
||||||
// not discovered later as two servers holding different halves of the mesh's traffic. A second
|
|
||||||
// bus is not a degraded mesh; it is two meshes that both believe they are the one.
|
|
||||||
func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) {
|
|
||||||
nats := catalogueManifest(t, "nats")
|
|
||||||
|
|
||||||
if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), World{}); err != nil {
|
|
||||||
t.Fatalf("the bus alone does not resolve: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
elsewhere := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh,
|
|
||||||
Node: "anchor", Module: "nats"}}}
|
|
||||||
other := workstation()
|
|
||||||
other.Name = "laptop"
|
|
||||||
_, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a second bus was accepted on another machine")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") {
|
|
||||||
t.Fatalf("refused without naming the seat: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different
|
|
||||||
// implementation of the bus claims the same seat, and the mesh refuses it for the same reason —
|
|
||||||
// which is the property that lets the bus be replaced at all.
|
|
||||||
func TestTheSeatRefusesADifferentBusToo(t *testing.T) {
|
|
||||||
nats := catalogueManifest(t, "nats")
|
|
||||||
held := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh,
|
|
||||||
Node: "anchor", Module: "some-other-broker"}}}
|
|
||||||
other := workstation()
|
|
||||||
other.Name = "laptop"
|
|
||||||
if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil {
|
|
||||||
t.Fatal("the seat admitted a second holder because the module's name differed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The old broker is gone from the catalogue (novox/hq ADR 0131, design 28 task 5.4), so it is no
|
// The old broker is gone from the catalogue (novox/hq ADR 0131, design 28 task 5.4), so it is no
|
||||||
// longer a fixture here. That two eligible holders stand beside each other with one on record is
|
// longer a fixture here. That two eligible holders stand beside each other with one on record is
|
||||||
// pinned in holdings_test.go against manifests this package owns.
|
// pinned in holdings_test.go against manifests this package owns.
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,159 +20,10 @@ func reachable() Node {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func onNetwork(nodes ...string) map[string][]Provider {
|
|
||||||
out := make([]Provider, 0, len(nodes))
|
|
||||||
for _, n := range nodes {
|
|
||||||
out = append(out, Provider{Node: n, At: n + ".internal", Module: "postgres"})
|
|
||||||
}
|
|
||||||
return map[string][]Provider{"postgres-database": out}
|
|
||||||
}
|
|
||||||
|
|
||||||
func brokeredShelf() map[string]Manifest {
|
|
||||||
return shelf(
|
|
||||||
Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database")},
|
|
||||||
Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestADatabaseIsNotInstalledOnEveryMachineThatUsesOne(t *testing.T) {
|
|
||||||
// The fault this whole distinction exists for.
|
|
||||||
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
|
||||||
World{Offered: onNetwork("anchor")})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if have := strings.Join(names(got), " "); strings.Contains(have, "postgres") {
|
|
||||||
t.Fatalf("using a database installed one here: %s", have)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhatAMachineTakesFromElsewhereIsRecorded(t *testing.T) {
|
|
||||||
// It is the only part of a node's set that stops working when a *different* machine goes
|
|
||||||
// away, and it is where a credential will have to be handed back.
|
|
||||||
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
|
||||||
World{Offered: onNetwork("anchor")})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got.Needs) != 1 {
|
|
||||||
t.Fatalf("got %v", got.Needs)
|
|
||||||
}
|
|
||||||
if got.Needs[0].Name != "postgres-database" || got.Needs[0].From != "anchor" {
|
|
||||||
t.Fatalf("got %v", got.Needs[0])
|
|
||||||
}
|
|
||||||
if got.Needs[0].For != "meshboard" {
|
|
||||||
t.Fatalf("it does not say what wanted it: %v", got.Needs[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNothingInTheMeshProvidingItIsRefusedWithSomewhereToPutIt(t *testing.T) {
|
|
||||||
// Refused rather than installed here. Choosing a machine to put a database on is a decision
|
|
||||||
// with consequences, and nothing resolving a web application should make it silently.
|
|
||||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(), World{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a database was found in a mesh that has none")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "assign") || !strings.Contains(err.Error(), "postgres") {
|
|
||||||
t.Fatalf("the refusal does not say what to do: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTwoNodesProvidingItIsRefusedRatherThanPicked(t *testing.T) {
|
|
||||||
// Same rule as everywhere else. Picking one would be a guess about which database a person
|
|
||||||
// meant, and the wrong guess is somebody's data in the wrong place.
|
|
||||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
|
||||||
World{Offered: onNetwork("anchor", "archive")})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("one of two databases was picked silently")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"anchor", "archive", "pin"} {
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Fatalf("the refusal does not name %s: %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSayingWhichOneSettlesIt(t *testing.T) {
|
|
||||||
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
|
||||||
World{
|
|
||||||
Offered: onNetwork("anchor", "archive"),
|
|
||||||
Pinned: map[string]Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got.Needs) != 1 || got.Needs[0].From != "archive" {
|
|
||||||
t.Fatalf("the choice was not taken: %v", got.Needs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) {
|
|
||||||
// Rather than falling back to one that does. A fallback would quietly move somebody's data to
|
|
||||||
// a machine they did not choose, which is the whole reason the question is asked.
|
|
||||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
|
||||||
World{
|
|
||||||
Offered: onNetwork("anchor", "archive"),
|
|
||||||
Pinned: map[string]Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}},
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a machine was silently given a different database from the one chosen")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "somewhere-else") {
|
|
||||||
t.Fatalf("the refusal does not say what was chosen: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOneProviderDoesNotOverruleAChoice(t *testing.T) {
|
|
||||||
// A single answer is normally taken silently. Not when somebody said they wanted a different
|
|
||||||
// one -- that is the mesh overruling a person, which it does nowhere else.
|
|
||||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
|
||||||
World{
|
|
||||||
Offered: onNetwork("anchor"),
|
|
||||||
Pinned: map[string]Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("the only database was used although another was chosen")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "only anchor/postgres provides it") {
|
|
||||||
t.Fatalf("the refusal does not say what is available: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestACatalogueThatDisagreesAboutScopeIsRefused(t *testing.T) {
|
|
||||||
// If one module says a database is local and another says it is anywhere, the same
|
|
||||||
// requirement means two things depending on which one happens to answer it.
|
|
||||||
_, err := Resolve(shelf(
|
|
||||||
Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database")},
|
|
||||||
Manifest{Module: "sqlite", Version: "1", Provides: Offers("postgres-database")},
|
|
||||||
Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}},
|
|
||||||
), []string{"meshboard"}, workstation(), World{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a catalogue that disagrees about where a database lives was accepted")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "two things") {
|
|
||||||
t.Fatalf("unhelpful refusal: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) {
|
|
||||||
// The change must not have made everything brokered. A shell is still installed here.
|
|
||||||
got, err := Resolve(shelf(
|
|
||||||
Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")},
|
|
||||||
Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}},
|
|
||||||
), []string{"tools"}, workstation(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") {
|
|
||||||
t.Fatalf("a shell was not installed on the machine that needs one: %s", have)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheShortFormStillMeansHere(t *testing.T) {
|
func TestTheShortFormStillMeansHere(t *testing.T) {
|
||||||
// `"provides": ["shell"]` must keep meaning what it meant, or every existing manifest
|
// `"provides": ["shell"]` must keep meaning what it meant, or every existing manifest
|
||||||
// silently changes meaning.
|
// silently changes meaning.
|
||||||
m, err := ParseManifest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
|
m, err := decodeForTest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -183,150 +32,4 @@ func TestTheShortFormStillMeansHere(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestASiteScopedProvisionIsRefused(t *testing.T) {
|
|
||||||
// Meaningful for a claim — one DHCP server per segment — and not yet meaningful for a
|
|
||||||
// provision, because nothing knows how to reach "the one at my site".
|
|
||||||
_, err := ParseManifest([]byte(
|
|
||||||
`{"module":"dns","version":"1","provides":[{"name":"resolver","scope":"site"}]}`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a site-scoped provision was accepted")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "site") {
|
|
||||||
t.Fatalf("unhelpful refusal: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Being told, which is the difference between knowing and being able to.
|
// Being told, which is the difference between knowing and being able to.
|
||||||
|
|
||||||
func boundShelf() map[string]Manifest {
|
|
||||||
return shelf(
|
|
||||||
Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database"),
|
|
||||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432, "driver": "postgres"}}},
|
|
||||||
Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"},
|
|
||||||
Binds: map[string]string{"postgres-database": "/etc/meshboard/database.json"}},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func binding(t *testing.T, out []map[string]any) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
for _, r := range out {
|
|
||||||
if r["path"] != "/etc/meshboard/database.json" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var parsed map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
|
||||||
t.Fatalf("what the app is told is not readable: %v", err)
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
t.Fatalf("the app was told nothing: %v", out)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAppIsToldWhereItsDatabaseIs(t *testing.T) {
|
|
||||||
// Knowing it needs the anchor's database is useless to the program that needs it unless the
|
|
||||||
// program is told. This is the whole point of the field.
|
|
||||||
got, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(),
|
|
||||||
World{Offered: map[string][]Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal",
|
|
||||||
Serves: map[string]any{"port": 5432, "driver": "postgres"}}}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
told := binding(t, mustDeclare(t, got))
|
|
||||||
if told["from"] != "anchor" || told["at"] != "anchor.internal" {
|
|
||||||
t.Fatalf("it was not told where: %v", told)
|
|
||||||
}
|
|
||||||
serves, _ := told["serves"].(map[string]any)
|
|
||||||
if serves["port"] != float64(5432) || serves["driver"] != "postgres" {
|
|
||||||
t.Fatalf("it was not told how: %v", serves)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestItSaysWhereTheCredentialIsInstead(t *testing.T) {
|
|
||||||
// A missing field looks like a bug; a stated absence looks like a boundary. Somebody wiring
|
|
||||||
// this up must not spend an afternoon looking for the password field.
|
|
||||||
//
|
|
||||||
// It used to say only that the mesh had no way to issue one, which stopped being true when
|
|
||||||
// 021 was fixed. A stated absence is only useful while it is accurate — once it is not, it
|
|
||||||
// sends the reader somewhere there is nothing to find.
|
|
||||||
got, _ := Resolve(boundShelf(), []string{"meshboard"}, reachable(),
|
|
||||||
World{Offered: map[string][]Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}})
|
|
||||||
told := binding(t, mustDeclare(t, got))
|
|
||||||
note, _ := told["generated"].(string)
|
|
||||||
if !strings.Contains(note, "not here") || !strings.Contains(note, "secrets") {
|
|
||||||
t.Fatalf("the file does not say where the credential is instead: %v", note)
|
|
||||||
}
|
|
||||||
for key := range told {
|
|
||||||
if strings.Contains(key, "password") || strings.Contains(key, "secret") {
|
|
||||||
t.Fatalf("something that looks like a credential appeared: %q", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTwoMachinesThatCannotReachEachOtherAreRefused(t *testing.T) {
|
|
||||||
// An app on one machine and a database on another that share no private network is a mesh
|
|
||||||
// that reports itself configured and does not work. Said here rather than discovered as a
|
|
||||||
// connection timing out.
|
|
||||||
_, err := Resolve(boundShelf(), []string{"meshboard"}, workstation(), // not on the network
|
|
||||||
World{Offered: map[string][]Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("an app was pointed at a database it has no path to")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "private network") {
|
|
||||||
t.Fatalf("the refusal does not say what is wrong: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), meshNetwork) {
|
|
||||||
t.Fatalf("the refusal does not say what to assign: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheProviderBeingOffTheNetworkIsAlsoRefused(t *testing.T) {
|
|
||||||
// Both directions, because the failure is identical from either end and the remedy differs.
|
|
||||||
_, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(),
|
|
||||||
World{Offered: map[string][]Provider{"postgres-database": {{Node: "anchor"}}}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("an app was pointed at a database that is not on the private network")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "anchor") {
|
|
||||||
t.Fatalf("the refusal does not name the unreachable end: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) {
|
|
||||||
// A file saying "it is on this node" is a fact nobody needs and one more thing to keep true.
|
|
||||||
got, err := Resolve(shelf(
|
|
||||||
Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")},
|
|
||||||
Manifest{Module: "tools", Version: "1", Requires: []string{"shell"},
|
|
||||||
Binds: map[string]string{"shell": "/etc/tools/shell.json"}},
|
|
||||||
), []string{"tools"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, r := range mustDeclare(t, got) {
|
|
||||||
if r["path"] == "/etc/tools/shell.json" {
|
|
||||||
t.Fatalf("a binding was written for something on this machine: %v", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBindingWhatYouDoNotRequireIsRefused(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"app","version":"1",
|
|
||||||
"binds":{"postgres-database":"/etc/app/db.json"}}`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a module was told about something it never asked for")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "does not require") {
|
|
||||||
t.Fatalf("unhelpful refusal: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"app","version":"1",
|
|
||||||
"serves":{"postgres-database":{"port":5432}}}`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a module served something it does not provide")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "does not provide") {
|
|
||||||
t.Fatalf("unhelpful refusal: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// BucketShare is one entry of `shares` on a module's requirement of `s3-bucket` (novox/hq ADR 0468 §8): who else may
|
// BucketShare is one entry of `shares` on a module's requirement of `s3-bucket` (novox/hq ADR 0468 §8): who else may
|
||||||
@@ -82,43 +81,3 @@ func readRequireShares(raw json.RawMessage) (map[string][]BucketShare, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move
|
|
||||||
// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered.
|
|
||||||
func sortedShares(shares []BucketShare) []BucketShare {
|
|
||||||
out := make([]BucketShare, 0, len(shares))
|
|
||||||
for _, s := range shares {
|
|
||||||
methods := append([]string(nil), s.Methods...)
|
|
||||||
sort.Strings(methods)
|
|
||||||
out = append(out, BucketShare{To: s.To, Methods: methods})
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own
|
|
||||||
// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8).
|
|
||||||
func withShares(m Manifest, requirement string, values map[string]any) (map[string]any, error) {
|
|
||||||
shares := m.RequireShares[requirement]
|
|
||||||
if _, named := values[SharesValue]; named && requirement == BucketRequirement {
|
|
||||||
return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)",
|
|
||||||
m.Module, requirement)
|
|
||||||
}
|
|
||||||
if len(shares) == 0 {
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
out := make(map[string]any, len(values)+1)
|
|
||||||
for k, v := range values {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
listed := []any{}
|
|
||||||
for _, s := range sortedShares(shares) {
|
|
||||||
methods := []any{}
|
|
||||||
for _, method := range s.Methods {
|
|
||||||
methods = append(methods, method)
|
|
||||||
}
|
|
||||||
listed = append(listed, map[string]any{"to": s.To, "methods": methods})
|
|
||||||
}
|
|
||||||
out[SharesValue] = listed
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,40 +12,6 @@ import (
|
|||||||
const filesWithShares = `{"module":"files","version":"1","requires":["shell",
|
const filesWithShares = `{"module":"files","version":"1","requires":["shell",
|
||||||
{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}`
|
{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}`
|
||||||
|
|
||||||
func minioLike() Manifest {
|
|
||||||
return Manifest{Module: "minio", Version: "1", Provides: Offers("s3-bucket"),
|
|
||||||
Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func givenToMinio(t *testing.T, out []map[string]any) []Contribution {
|
|
||||||
t.Helper()
|
|
||||||
for _, r := range out {
|
|
||||||
if r["path"] != "/grants/mesh.json" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var parsed struct {
|
|
||||||
Given []Contribution `json:"given"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return parsed.Given
|
|
||||||
}
|
|
||||||
t.Fatalf("minio was given no file: %v", out)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sharesOf(t *testing.T, values map[string]any) string {
|
|
||||||
t.Helper()
|
|
||||||
raw, err := json.Marshal(values["shares"])
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]`
|
|
||||||
|
|
||||||
// 1591/AC1: parsed, the shares are on the manifest; stored, `requires` is plain names and the side key holds them.
|
// 1591/AC1: parsed, the shares are on the manifest; stored, `requires` is plain names and the side key holds them.
|
||||||
func TestSharesParseAndAreStoredBesideRequires(t *testing.T) {
|
func TestSharesParseAndAreStoredBesideRequires(t *testing.T) {
|
||||||
m := mustParse(t, filesWithShares)
|
m := mustParse(t, filesWithShares)
|
||||||
@@ -88,7 +54,7 @@ func TestAMalformedShareIsRefused(t *testing.T) {
|
|||||||
"other require": `{"name":"route","shares":[{"to":"a","methods":["get"]}]}`,
|
"other require": `{"name":"route","shares":[{"to":"a","methods":["get"]}]}`,
|
||||||
}
|
}
|
||||||
for name, entry := range cases {
|
for name, entry := range cases {
|
||||||
_, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`))
|
_, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("%s: accepted", name)
|
t.Errorf("%s: accepted", name)
|
||||||
continue
|
continue
|
||||||
@@ -98,88 +64,9 @@ func TestAMalformedShareIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The stored form is held to the same rules.
|
// The stored form is held to the same rules.
|
||||||
_, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"],
|
_, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"],
|
||||||
"requires-shares":{"s3-bucket":[{"to":"a","methods":[]}]}}`))
|
"requires-shares":{"s3-bucket":[{"to":"a","methods":[]}]}}`))
|
||||||
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
|
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
|
||||||
t.Errorf("the stored form was accepted: %v", err)
|
t.Errorf("the stored form was accepted: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike.
|
|
||||||
func TestTheGrantCarriesTheSharesSorted(t *testing.T) {
|
|
||||||
m := mustParse(t, filesWithShares)
|
|
||||||
remote := Resolution{Node: "laptop", Modules: []Manifest{m}}
|
|
||||||
values, asks, err := remote.ContributionsFrom("s3-bucket", "files", SettingsBy{})
|
|
||||||
if err != nil || !asks {
|
|
||||||
t.Fatalf("asks %v, err %v", asks, err)
|
|
||||||
}
|
|
||||||
if got := sharesOf(t, values); got != sortedSharesJSON {
|
|
||||||
t.Fatalf("the grant values carry %s", got)
|
|
||||||
}
|
|
||||||
// As the provider's file shows it, through a grant.
|
|
||||||
provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := provider.Declaration(Rendering{Grants: []Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
given := givenToMinio(t, out)
|
|
||||||
if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON {
|
|
||||||
t.Fatalf("the remote consumer's shares did not reach the file: %+v", given)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Co-located: the consumer contributes to s3-bucket too, and no grant is passed.
|
|
||||||
co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}},
|
|
||||||
"requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`)
|
|
||||||
both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err = both.Declaration(Rendering{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
given = givenToMinio(t, out)
|
|
||||||
if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" {
|
|
||||||
t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1591/AC4: only the shares change, so the module is not recreated and held for no one; the provider's file changes.
|
|
||||||
func TestChangingOnlyTheSharesDoesNotRecreateTheConsumer(t *testing.T) {
|
|
||||||
was := mustParse(t, filesWithShares)
|
|
||||||
now := mustParse(t, `{"module":"files","version":"1","requires":["shell",
|
|
||||||
{"name":"s3-bucket","shares":[{"to":"print-and-scan","methods":["get"]}]}]}`)
|
|
||||||
if said := Recreates(was, now).Say("files"); said != "" {
|
|
||||||
t.Errorf("a change of shares recreates the module: %s", said)
|
|
||||||
}
|
|
||||||
if said := Holds(was, now); said != "" {
|
|
||||||
t.Errorf("a change of shares holds the module: %s", said)
|
|
||||||
}
|
|
||||||
before := Resolution{Node: "laptop", Modules: []Manifest{was}}
|
|
||||||
after := Resolution{Node: "laptop", Modules: []Manifest{now}}
|
|
||||||
a, _, _ := before.ContributionsFrom("s3-bucket", "files", SettingsBy{})
|
|
||||||
b, _, _ := after.ContributionsFrom("s3-bucket", "files", SettingsBy{})
|
|
||||||
if sharesOf(t, a) == sharesOf(t, b) {
|
|
||||||
t.Fatalf("the provider's values did not change: %s", sharesOf(t, a))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused.
|
|
||||||
func TestAConsumerCannotContributeSharesItself(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"files","version":"1",
|
|
||||||
"contributes":{"s3-bucket":{"shares":"x"}},
|
|
||||||
"requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
|
|
||||||
t.Errorf("both written, accepted: %v", err)
|
|
||||||
}
|
|
||||||
// Without the field the grant is still refused, so the provider is never told something the manifest did not say.
|
|
||||||
m := Manifest{Module: "files", Requires: []string{"s3-bucket"},
|
|
||||||
Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}}
|
|
||||||
r := Resolution{Node: "laptop", Modules: []Manifest{m}}
|
|
||||||
if _, _, err := r.ContributionsFrom("s3-bucket", "files", SettingsBy{}); err == nil {
|
|
||||||
t.Error("a contributed `shares` reached the grant")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+23
-375
@@ -2,25 +2,11 @@ package catalogue
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/url"
|
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Turning a manifest that names artifacts into one that names digests.
|
|
||||||
//
|
|
||||||
// **Two documents, deliberately.** The manifest in a repository says *this resource uses the
|
|
||||||
// archive called `config`*; the manifest the mesh holds says *this resource is sha256:…*. A digest
|
|
||||||
// is not knowable until something is built, so a repository carrying one would be a repository
|
|
||||||
// whose file is wrong the moment anybody edits anything — and the mesh would be pinning a value
|
|
||||||
// nobody could have checked.
|
|
||||||
//
|
|
||||||
// So the built manifest is **derived**, and the record of which commit it was derived from is what
|
|
||||||
// makes "is this current?" answerable without building (novox/hq ADR 0009).
|
|
||||||
|
|
||||||
// Built is one artifact after it exists: where it is and what it hashes to.
|
// Built is one artifact after it exists: where it is and what it hashes to.
|
||||||
type Built struct {
|
type Built struct {
|
||||||
// Name is what the manifest called it.
|
// Name is what the manifest called it.
|
||||||
@@ -126,23 +112,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made := by[a.Name]
|
made := by[a.Name]
|
||||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
loads := BundleLoads(m, a, run)
|
||||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
|
||||||
//
|
|
||||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
|
||||||
// A process the host runs is the module's service, not its tools: the controller declares
|
|
||||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
|
||||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
|
||||||
loads := append([]string(nil), a.Loads...)
|
|
||||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
|
||||||
loads = append([]string(nil), a.Entrypoints...)
|
|
||||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
|
||||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
|
||||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
|
||||||
if bin := BinaryOf(a); bin != "" {
|
|
||||||
loads = []string{bin}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
||||||
// it reached it by, and a manifest carrying that address names an installation —
|
// it reached it by, and a manifest carrying that address names an installation —
|
||||||
// registration refused node-tools for exactly this on 2026-10-02. The build record
|
// registration refused node-tools for exactly this on 2026-10-02. The build record
|
||||||
@@ -152,7 +122,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
||||||
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
||||||
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
||||||
Binary: BinaryOf(a),
|
Binary: BinaryOf(a), Bus: a.Bus,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
||||||
@@ -253,152 +223,6 @@ func withVersion(value any, version string) any {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkBuild is the manifest's own account of what it builds.
|
|
||||||
func (b *Build) problems(module string) []string {
|
|
||||||
if b == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, a := range b.Artifacts {
|
|
||||||
if a.Name == "" {
|
|
||||||
problems = append(problems, module+" builds an artifact with no name")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if seen[a.Name] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s builds two artifacts called %q, and a resource naming it could mean either",
|
|
||||||
module, a.Name))
|
|
||||||
}
|
|
||||||
seen[a.Name] = true
|
|
||||||
switch a.Kind {
|
|
||||||
case ArtifactImage, ArtifactArchive, ArtifactUpstream, ArtifactBundle, ArtifactPackage:
|
|
||||||
default:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a %q, and an artifact is %q, %q, %q or %q",
|
|
||||||
module, a.Name, a.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
|
|
||||||
ArtifactBundle+", "+ArtifactPackage))
|
|
||||||
}
|
|
||||||
// **A bundle is built from the module itself, so it says a language instead.** Everything
|
|
||||||
// else names what it is built from: a Dockerfile, a directory, somebody else's reference.
|
|
||||||
// A bundle's source is the module's own directory by definition, and what it needs to say
|
|
||||||
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
|
||||||
// has not said.
|
|
||||||
if len(a.External) > 0 && (a.Kind != ArtifactBundle || a.Language != "typescript") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+
|
|
||||||
"one file with some kept out (novox/hq ADR 0193)", module, a.Name))
|
|
||||||
}
|
|
||||||
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
|
|
||||||
"serves is given words (novox/hq ADR 0192); a container says its own environment",
|
|
||||||
module, a.Name, a.Kind))
|
|
||||||
}
|
|
||||||
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
|
||||||
// **Except for a language that compiles to a binary, where it names which one**
|
|
||||||
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
|
||||||
// directory compiled whole, and naming a source would be describing its own build. A
|
|
||||||
// repository written in a compiled language holds several commands — the host and its
|
|
||||||
// bootstrap live in one, and the mesh needs the host — and "the module's own directory"
|
|
||||||
// is then not a package at all. So the compiled case may say which package, and says
|
|
||||||
// the module root by saying nothing.
|
|
||||||
if a.From != "" && !compilesToABinary(a.Language) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
|
|
||||||
"from the module's own directory; what it says is the language",
|
|
||||||
module, a.Name, a.From))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(a.Language) == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
|
||||||
"for it", module, a.Name))
|
|
||||||
}
|
|
||||||
problems = append(problems, bundleEnvProblems(module, a)...)
|
|
||||||
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
|
|
||||||
// not an entrypoint is a file the bundle does not contain, and the runtime would
|
|
||||||
// fail to import it on every machine rather than here.
|
|
||||||
for _, load := range a.Loads {
|
|
||||||
found := false
|
|
||||||
for _, e := range a.Entrypoints {
|
|
||||||
found = found || e == load
|
|
||||||
}
|
|
||||||
// A bundle compiled to a binary is one executable: the runtime loads that or nothing
|
|
||||||
// (novox/hq ADR 0193).
|
|
||||||
if bin := BinaryOf(a); bin != "" {
|
|
||||||
found = load == bin
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
|
|
||||||
"what is loaded is compiled, so it is named there too", module, a.Name, load))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
|
|
||||||
// is pinned to one operating system at link time so a host refuses to touch a machine
|
|
||||||
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
|
|
||||||
// compiled for whatever the build machine happened to be, which reads as portable and
|
|
||||||
// is not.
|
|
||||||
if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is compiled to a binary and says no system, so it would be built for "+
|
|
||||||
"whatever the build machine happens to be. Declare one artifact per "+
|
|
||||||
"system: %s", module, a.Name, spokenSystems()))
|
|
||||||
} else if !compiled && strings.TrimSpace(a.System) != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q names the system %q and is written in %q, which compiles to code that "+
|
|
||||||
"runs anywhere — a system that decides nothing reads as though it did",
|
|
||||||
module, a.Name, a.System, a.Language))
|
|
||||||
} else if compiled && !knownSystem(a.System) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is built for %q, and a system is %s",
|
|
||||||
module, a.Name, a.System, spokenSystems()))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if a.From == "" && a.URL == "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q says nothing about what it is built from", module, a.Name))
|
|
||||||
}
|
|
||||||
if a.Language != "" {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+
|
|
||||||
"everything else brings its own recipe", module, a.Name, a.Kind))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if a.Compiles != "" {
|
|
||||||
if a.Kind != ArtifactImage {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is a %q and names a Go package it compiles. Only an image's recipe is "+
|
|
||||||
"told which; a bundle names its package in from (novox/hq ADR 0267)", module, a.Name, a.Kind))
|
|
||||||
} else if c := strings.TrimSpace(a.Compiles); strings.HasPrefix(c, "/") || c == ".." ||
|
|
||||||
strings.HasPrefix(c, "../") || strings.Contains(c, "/../") || strings.HasSuffix(c, "/..") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q compiles %q, which leaves the tree it is built in", module, a.Name, a.Compiles))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// An upstream image is named, not read from the repository, so the path rule does not
|
|
||||||
// apply to it — and applying it anyway would refuse every reference with a registry host
|
|
||||||
// in it.
|
|
||||||
if a.Kind != ArtifactUpstream &&
|
|
||||||
(strings.HasPrefix(a.From, "/") || strings.Contains(a.From, "..")) {
|
|
||||||
// A build reads its own repository and nothing else. A path leaving it would make
|
|
||||||
// what gets built depend on whatever happens to be on the machine building it.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q is built from %q, which is outside its own repository",
|
|
||||||
module, a.Name, a.From))
|
|
||||||
}
|
|
||||||
if a.Kind == ArtifactUpstream && !strings.Contains(a.From, ":") {
|
|
||||||
// Without a tag or digest, what gets mirrored is whatever `latest` means today, and
|
|
||||||
// a module pinned to that is not pinned.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From))
|
|
||||||
}
|
|
||||||
problems = append(problems, upstreamFileProblems(module, a)...)
|
|
||||||
problems = append(problems, pathsProblems(module, a)...)
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the
|
// NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the
|
||||||
// paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact
|
// paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact
|
||||||
// names paths; otherwise none, and the module keeps its directory's tree.
|
// names paths; otherwise none, and the module keeps its directory's tree.
|
||||||
@@ -426,122 +250,6 @@ func (m Manifest) NamedPaths() (paths []string, every bool) {
|
|||||||
return paths, true
|
return paths, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// pathsProblems is what is wrong with an artifact's `paths` (novox/hq ADR 0499 clause 1): named on a kind whose build
|
|
||||||
// source is derived or named otherwise, or a path that leaves the module's directory, is its whole directory, or is a
|
|
||||||
// pattern.
|
|
||||||
func pathsProblems(module string, a Artifact) []string {
|
|
||||||
if a.Paths == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case a.Kind == ArtifactBundle && a.Language == "go":
|
|
||||||
return []string{fmt.Sprintf("%s: %q is a Go program and names paths: its build source is derived from the "+
|
|
||||||
"package it compiles (novox/hq ADR 0267 clause 1, ADR 0499 clause 1)", module, a.Name)}
|
|
||||||
case a.Kind != ArtifactBundle && a.Kind != ArtifactPackage:
|
|
||||||
return []string{fmt.Sprintf("%s: %q is a %q and names paths; only a package or a bundle in a language other "+
|
|
||||||
"than Go names the paths it is built from (novox/hq ADR 0499 clause 1)", module, a.Name, a.Kind)}
|
|
||||||
case len(a.Paths) == 0:
|
|
||||||
return []string{fmt.Sprintf("%s: %q names no path in paths; leave the word out to build from the module's "+
|
|
||||||
"directory", module, a.Name)}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, p := range a.Paths {
|
|
||||||
clean := path.Clean(p)
|
|
||||||
switch {
|
|
||||||
case strings.TrimSpace(p) == "":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %q names an empty path", module, a.Name))
|
|
||||||
case strings.HasPrefix(p, "/") || clean == ".." || strings.HasPrefix(clean, "../"):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %q names the path %q, which leaves the module's directory",
|
|
||||||
module, a.Name, p))
|
|
||||||
case clean == ".":
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %q names the module's whole directory (%q) as a path; leave "+
|
|
||||||
"the word out for that", module, a.Name, p))
|
|
||||||
case strings.ContainsAny(p, "*?[]{}\\"):
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %q names %q, which is a pattern; paths names files and "+
|
|
||||||
"directories", module, a.Name, p))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// reservedSuffixes are names no public vendor publishes under: an installation's own and documentation's.
|
|
||||||
var reservedSuffixes = []string{".internal", ".local", ".lan", ".home", ".corp", ".localhost", ".home.arpa",
|
|
||||||
".test", ".invalid", ".example", ".intranet", ".private"}
|
|
||||||
|
|
||||||
// notAPublicHost says why a host is not a public vendor's name, or "".
|
|
||||||
func notAPublicHost(host string) string {
|
|
||||||
h := strings.ToLower(strings.TrimSuffix(host, "."))
|
|
||||||
if net.ParseIP(h) != nil {
|
|
||||||
return "an address rather than a name"
|
|
||||||
}
|
|
||||||
if !strings.Contains(h, ".") {
|
|
||||||
return "a name with no domain"
|
|
||||||
}
|
|
||||||
for _, s := range reservedSuffixes {
|
|
||||||
if strings.HasSuffix(h, s) {
|
|
||||||
return "a name under " + s + ", which is nobody's public domain"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// sha256Hex is a pinned sha256 as a manifest writes it: 64 lower-case hexadecimal characters.
|
|
||||||
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
||||||
|
|
||||||
// upstreamFileProblems is what is wrong with an archive pinned by URL (novox/hq ADR 0316 §2, §6): it is an
|
|
||||||
// archive, fetched over https, pinned by a sha256 and a version, and it names no directory of the repository
|
|
||||||
// besides; a file name or a directory within is a plain path that stays where it is put.
|
|
||||||
func upstreamFileProblems(module string, a Artifact) []string {
|
|
||||||
pinned := a.URL != "" || a.SHA256 != "" || a.Version != "" || a.File != "" || a.Within != ""
|
|
||||||
if !pinned {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
say := func(format string, args ...any) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %q ", module, a.Name)+fmt.Sprintf(format, args...))
|
|
||||||
}
|
|
||||||
if a.Kind != ArtifactArchive {
|
|
||||||
say("is a %q and names an upstream file (url, sha256, version, file, within): only an archive is "+
|
|
||||||
"mirrored from one (novox/hq ADR 0316)", a.Kind)
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
if a.URL == "" {
|
|
||||||
say("names a sha256, a version, a file or a directory within, and no url to fetch it from")
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
if a.From != "" {
|
|
||||||
say("is built from %q and fetched from %s: an archive is one or the other", a.From, a.URL)
|
|
||||||
}
|
|
||||||
if u, err := url.Parse(a.URL); err != nil || u.Scheme != "https" || u.Host == "" {
|
|
||||||
say("is fetched from %q, and an upstream file is fetched over https from a named host", a.URL)
|
|
||||||
} else if why := notAPublicHost(u.Hostname()); why != "" {
|
|
||||||
say("is fetched from %s, %s: an upstream file is somebody else's release, published under a public name", a.URL, why)
|
|
||||||
}
|
|
||||||
if !sha256Hex.MatchString(a.SHA256) {
|
|
||||||
say("is fetched from %s without a pinned sha256 (64 lower-case hexadecimal characters): what that "+
|
|
||||||
"address serves changes under the mesh, and only the digest says it is what was reviewed", a.URL)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(a.Version) == "" {
|
|
||||||
say("pins %s and says no version, so a person reading the pin cannot tell which release it is", a.URL)
|
|
||||||
}
|
|
||||||
if a.File != "" && a.Within != "" {
|
|
||||||
say("names a file (%q) and a directory within (%q): a file is kept whole, a directory is taken out "+
|
|
||||||
"of an archive", a.File, a.Within)
|
|
||||||
}
|
|
||||||
for field, p := range map[string]string{"file": a.File, "within": a.Within} {
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
clean := path.Clean(p)
|
|
||||||
if clean != p || strings.HasPrefix(p, "/") || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") ||
|
|
||||||
(field == "file" && strings.Contains(p, "/")) {
|
|
||||||
say("names %s %q, which is not a plain relative %s", field, p, map[string]string{
|
|
||||||
"file": "file name", "within": "directory"}[field])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// oneOrOther keeps the message readable for one artifact and for several, because a message that
|
// oneOrOther keeps the message readable for one artifact and for several, because a message that
|
||||||
// says "neither" about one thing reads as a bug in the message.
|
// says "neither" about one thing reads as a bug in the message.
|
||||||
func oneOrOther(n int) string {
|
func oneOrOther(n int) string {
|
||||||
@@ -551,30 +259,6 @@ func oneOrOther(n int) string {
|
|||||||
return "them"
|
return "them"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005).
|
|
||||||
//
|
|
||||||
// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain
|
|
||||||
// would call an operating system — the difference between two of them is a C library, not a kernel.
|
|
||||||
// They are what a machine reports itself to be and what a host is linked to refuse, so the list that
|
|
||||||
// matters is the one the host understands.
|
|
||||||
var systems = []string{"alpine", "android", "arch"}
|
|
||||||
|
|
||||||
// knownSystem is whether the mesh builds for it.
|
|
||||||
func knownSystem(system string) bool {
|
|
||||||
want := strings.ToLower(strings.TrimSpace(system))
|
|
||||||
for _, s := range systems {
|
|
||||||
if s == want {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// spokenSystems is the list as a refusal says it, so a reader is one edit from right.
|
|
||||||
func spokenSystems() string {
|
|
||||||
return strings.Join(systems, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
|
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
|
||||||
// than code that runs wherever its interpreter does.
|
// than code that runs wherever its interpreter does.
|
||||||
//
|
//
|
||||||
@@ -615,42 +299,6 @@ func versionOf(digest string) string {
|
|||||||
return hex
|
return hex
|
||||||
}
|
}
|
||||||
|
|
||||||
// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be
|
|
||||||
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
|
||||||
var bundleEnvWords = map[string]bool{
|
|
||||||
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
|
||||||
RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
|
||||||
// a value is a path or a constant written with the references a container's environment may use
|
|
||||||
// for a place or a port, and never a secret's content or another module's binding — a secret
|
|
||||||
// reaches a tool as a file whose path is named.
|
|
||||||
func bundleEnvProblems(module string, a Artifact) []string {
|
|
||||||
if len(a.Env) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, word := range sortedKeys(a.Env) {
|
|
||||||
value := a.Env[word]
|
|
||||||
if bundleEnvWords[word] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+
|
|
||||||
"given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)",
|
|
||||||
module, a.Name, word))
|
|
||||||
}
|
|
||||||
rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "")
|
|
||||||
if strings.Contains(rest, "${") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+
|
|
||||||
"${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+
|
|
||||||
"named by its path, never as its content (novox/hq ADR 0192)",
|
|
||||||
module, a.Name, word, value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyWords(in map[string]string) map[string]string {
|
func copyWords(in map[string]string) map[string]string {
|
||||||
if len(in) == 0 {
|
if len(in) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -691,27 +339,27 @@ func runByAResource(m Manifest) map[string]bool {
|
|||||||
return named
|
return named
|
||||||
}
|
}
|
||||||
|
|
||||||
// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq
|
// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs,
|
||||||
// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says
|
// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a
|
||||||
// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or
|
// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a
|
||||||
// it is the runtime itself. One reached by none of them was built, recorded and pushed as success,
|
// module composed as what runs of it reads again when the runtime already dropped it (issue 1126).
|
||||||
// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here,
|
func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
|
||||||
// naming the field that would deliver it.
|
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||||
func undeliveredBundles(m Manifest) []string {
|
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||||
if m.Build == nil || m.Module == RuntimeModule {
|
//
|
||||||
return nil
|
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||||
|
// A process the host runs is the module's service, not its tools: the controller declares
|
||||||
|
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||||
|
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||||
|
loads := append([]string(nil), a.Loads...)
|
||||||
|
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||||
|
loads = append([]string(nil), a.Entrypoints...)
|
||||||
|
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||||
|
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||||
|
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
||||||
|
if bin := BinaryOf(a); bin != "" {
|
||||||
|
loads = []string{bin}
|
||||||
}
|
}
|
||||||
named := runByAResource(m)
|
|
||||||
var problems []string
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
problems = append(problems, fmt.Sprintf(
|
return loads
|
||||||
"%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+
|
|
||||||
"unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+
|
|
||||||
"module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)",
|
|
||||||
m.Module, a.Name))
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,8 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// bundleFor is a manifest whose one artifact is a bundle in the given language and system.
|
// bundleFor is a manifest whose one artifact is a bundle in the given language and system.
|
||||||
func bundleFor(language, system string) Manifest {
|
func bundleFor(language, system string) Manifest {
|
||||||
return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{
|
return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{
|
||||||
{Name: "binary", Kind: ArtifactBundle, Language: language, System: system},
|
{Name: "binary", Kind: ArtifactBundle, Language: language, System: system},
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func problemsOf(t *testing.T, m Manifest) string {
|
|
||||||
t.Helper()
|
|
||||||
return strings.Join(m.Build.problems(m.Module), "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A language that compiles to a binary must say which system.**
|
|
||||||
//
|
|
||||||
// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it
|
|
||||||
// was not built for. An artifact that says nothing would be compiled for whatever the build machine
|
|
||||||
// happened to be — which reads as portable and is not, and is the fault this check exists for.
|
|
||||||
func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) {
|
|
||||||
got := problemsOf(t, bundleFor("go", ""))
|
|
||||||
if !strings.Contains(got, "says no system") {
|
|
||||||
t.Fatalf("a compiled bundle with no system was accepted:\n%s", got)
|
|
||||||
}
|
|
||||||
// And the refusal names what it could have said, so a reader is one edit from right.
|
|
||||||
for _, system := range []string{"alpine", "android", "arch"} {
|
|
||||||
if !strings.Contains(got, system) {
|
|
||||||
t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestABinaryThatNamesASystemIsAccepted(t *testing.T) {
|
|
||||||
if got := problemsOf(t, bundleFor("go", "arch")); got != "" {
|
|
||||||
t.Fatalf("a compiled bundle naming a system was refused:\n%s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A system the mesh does not build for is refused where it is written. These are the host's own
|
|
||||||
// names, not a compiler's: the difference between two of them is a C library rather than a kernel,
|
|
||||||
// so a value that looks like an operating system to a toolchain is still wrong here.
|
|
||||||
func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) {
|
|
||||||
for _, wrong := range []string{"linux", "debian", "darwin"} {
|
|
||||||
got := problemsOf(t, bundleFor("go", wrong))
|
|
||||||
if !strings.Contains(got, "and a system is") {
|
|
||||||
t.Fatalf("%q was accepted as a system:\n%s", wrong, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **And a language that runs anywhere must not name one.** A system that decides nothing reads as
|
|
||||||
// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045).
|
|
||||||
func TestAPortableBundleMayNotNameASystem(t *testing.T) {
|
|
||||||
got := problemsOf(t, bundleFor("typescript", "arch"))
|
|
||||||
if !strings.Contains(got, "runs anywhere") {
|
|
||||||
t.Fatalf("a portable bundle was allowed to name a system:\n%s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) {
|
|
||||||
if got := problemsOf(t, bundleFor("typescript", "")); got != "" {
|
|
||||||
t.Fatalf("an ordinary bundle was refused:\n%s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the
|
|
||||||
// reason the target is the artifact's rather than the recipe's.
|
|
||||||
func TestOneArtifactPerSystemIsAccepted(t *testing.T) {
|
|
||||||
m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{
|
|
||||||
{Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"},
|
|
||||||
{Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"},
|
|
||||||
{Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"},
|
|
||||||
}}}
|
|
||||||
if got := problemsOf(t, m); got != "" {
|
|
||||||
t.Fatalf("one artifact per system was refused:\n%s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -108,68 +108,8 @@ func TestAModuleThatBuildsNothingIsOrdinary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTwoArtifactsWithOneNameAreRefused(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
||||||
{"name":"x","kind":"image","from":"Dockerfile"},
|
|
||||||
{"name":"x","kind":"archive","from":"files"}]}}`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("two artifacts with one name were accepted")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "could mean either") {
|
|
||||||
t.Fatalf("unhelpful refusal: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnArtifactBuiltFromOutsideItsRepositoryIsRefused(t *testing.T) {
|
|
||||||
// A build reads its own repository and nothing else. A path leaving it would make what gets
|
|
||||||
// built depend on whatever happens to be on the machine doing the building.
|
|
||||||
for _, from := range []string{"/etc/passwd", "../elsewhere"} {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
||||||
{"name":"x","kind":"archive","from":"` + from + `"}]}}`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("%q was accepted as a build input", from)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "outside its own repository") {
|
|
||||||
t.Fatalf("refused for the wrong reason: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnArtifactOfAnUnknownKindIsRefused(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
||||||
{"name":"x","kind":"binary","from":"main.go"}]}}`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("an artifact of an unknown kind was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) {
|
|
||||||
// The SDK's shape: a package built from the module's own directory, naming a language.
|
|
||||||
m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk",
|
|
||||||
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
|
||||||
"resources":[]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("the SDK's package manifest did not parse: %v", err)
|
|
||||||
}
|
|
||||||
if m.Build.Artifacts[0].Kind != ArtifactPackage {
|
|
||||||
t.Fatalf("expected a package artifact, got %q", m.Build.Artifacts[0].Kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A package that names what it is built from is refused, exactly as a bundle is: it is built
|
|
||||||
// from the module's own directory.
|
|
||||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
||||||
{"name":"lib","kind":"package","language":"typescript","from":"Dockerfile"}]}}`)); err == nil {
|
|
||||||
t.Fatal("a package naming a source was accepted")
|
|
||||||
}
|
|
||||||
// A package with no language cannot choose a toolchain.
|
|
||||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
||||||
{"name":"lib","kind":"package"}]}}`)); err == nil {
|
|
||||||
t.Fatal("a package with no language was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAResourceNamingAPackageIsRefused(t *testing.T) {
|
func TestAResourceNamingAPackageIsRefused(t *testing.T) {
|
||||||
m, err := ParseManifest([]byte(`{"module":"a","version":"1","slug":"a",
|
m, err := decodeForTest([]byte(`{"module":"a","version":"1","slug":"a",
|
||||||
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
||||||
"resources":[{"id":"svc","type":"container","name":"a","artifact":"lib"}]}`))
|
"resources":[{"id":"svc","type":"container","name":"a","artifact":"lib"}]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -180,24 +120,3 @@ func TestAResourceNamingAPackageIsRefused(t *testing.T) {
|
|||||||
t.Fatal("a resource backed by a package was accepted; a package is not a resource")
|
t.Fatal("a resource backed by a package was accepted; a package is not a resource")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in,
|
|
||||||
// and only an image says one.
|
|
||||||
func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) {
|
|
||||||
m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
||||||
{"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`))
|
|
||||||
if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" {
|
|
||||||
t.Fatalf("an image naming its package was refused or lost it: %v", err)
|
|
||||||
}
|
|
||||||
for _, c := range []struct{ artifact, says string }{
|
|
||||||
{`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"},
|
|
||||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"},
|
|
||||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"},
|
|
||||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"},
|
|
||||||
} {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
||||||
t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,40 +9,6 @@ import (
|
|||||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and
|
|
||||||
// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted
|
|
||||||
// nothing, so it is refused at the parser rather than silently dropped.
|
|
||||||
func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
|
|
||||||
raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
|
||||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
|
||||||
"emits":["something.happened"]}`)
|
|
||||||
_, err := ParseManifest(raw)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") {
|
|
||||||
t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is
|
|
||||||
// granted the module's events, so a bus module that ships one may declare them.
|
|
||||||
bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
|
||||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
|
||||||
"emits":["bus.slow-consumer"],"tools":["bus_streams"],
|
|
||||||
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`)
|
|
||||||
if _, err := ParseManifest(bundled); err != nil {
|
|
||||||
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
|
||||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
|
||||||
"tools":["bus_streams"]}`)
|
|
||||||
m, err := ParseManifest(quiet)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("a bus module with an account and tools was refused: %v", err)
|
|
||||||
}
|
|
||||||
if !m.ClaimsSeat(BrokerSeat) {
|
|
||||||
t.Fatal("it does not read as holding the broker seat")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a
|
// The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a
|
||||||
// dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot
|
// dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot
|
||||||
// program in the bus's own container.
|
// program in the bus's own container.
|
||||||
@@ -51,7 +17,7 @@ func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
m, err := ParseManifest(raw)
|
m, err := decodeForTest(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A long-running container may say it is busy (novox/hq ADR 0335 §1).
|
|
||||||
//
|
|
||||||
// **Beside its health, on the container**: a command run inside it, by its shell, as an exec health check
|
|
||||||
// is, that exits 0 while the container holds work a replacement would lose and non-zero when it is idle;
|
|
||||||
// and how long one ask may take. Being asked is also its notice that it is about to be replaced: from the
|
|
||||||
// first ask it takes no new work. The node-engine asks before it replaces the container, leaves it running
|
|
||||||
// while it says busy, and replaces it on the first pass it says idle, or at 15 minutes — the engine's bound,
|
|
||||||
// never the module's.
|
|
||||||
//
|
|
||||||
// Sent only to a node-engine that reads it (link.BusyContract): an older, strict engine refuses a field it
|
|
||||||
// does not know, whole, and to it the container is replaced at once, as before.
|
|
||||||
|
|
||||||
// BusyField is the resource field.
|
// BusyField is the resource field.
|
||||||
const BusyField = "busy"
|
const BusyField = "busy"
|
||||||
|
|
||||||
@@ -79,46 +67,3 @@ func ReadBusy(r map[string]any) (Busy, bool, []string) {
|
|||||||
}
|
}
|
||||||
return b, true, problems
|
return b, true, problems
|
||||||
}
|
}
|
||||||
|
|
||||||
// busyProblems is everything wrong with a manifest's `busy` fields, in the manifest's words: a command on
|
|
||||||
// one line, a timeout within its bounds, and only on a container that stays up.
|
|
||||||
func busyProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
b, has, read := ReadBusy(r)
|
|
||||||
if !has {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
where := m.Module + ": " + fmt.Sprint(r["id"])
|
|
||||||
for _, p := range read {
|
|
||||||
problems = append(problems, m.Module+": "+p)
|
|
||||||
}
|
|
||||||
if fmt.Sprint(r["type"]) != "container" || !LongRunning(r) {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s declares busy and is not a container that stays up: busy is "+
|
|
||||||
"asked of a running container before it is replaced (ADR 0335)", where))
|
|
||||||
}
|
|
||||||
if b.Command == "" {
|
|
||||||
problems = append(problems, where+"'s busy names no command: one run inside the container, exiting 0 "+
|
|
||||||
"while it is busy")
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(b.Command, "\n\r") {
|
|
||||||
problems = append(problems, where+"'s busy command is one line")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
|
|
||||||
// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
|
|
||||||
// container is replaced at once, as before.
|
|
||||||
func busyInto(resource map[string]any, with Rendering) {
|
|
||||||
if _, has := resource[BusyField]; !has {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !with.ReadsBusy {
|
|
||||||
delete(resource, BusyField)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b, _, _ := ReadBusy(resource)
|
|
||||||
resource[BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/validate"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A long-running container may say it is busy (novox/hq ADR 0335 §1): `module check` holds the field to its
|
|
||||||
// shape near its author, and it is sent only to a node-engine that reads it, in words the engine's own
|
|
||||||
// validator accepts.
|
|
||||||
|
|
||||||
func busyManifest(t *testing.T, on string, busy any) []byte {
|
|
||||||
t.Helper()
|
|
||||||
extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent",
|
|
||||||
"image": "registry.example/app" + healthDigest}
|
|
||||||
var m map[string]any
|
|
||||||
if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, r := range m["resources"].([]any) {
|
|
||||||
if r := r.(map[string]any); r["id"] == on {
|
|
||||||
r[BusyField] = busy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestABusyIsHeldToItsShape(t *testing.T) {
|
|
||||||
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil {
|
|
||||||
t.Fatalf("a well-formed busy was refused: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil {
|
|
||||||
t.Fatalf("a busy with its timeout was refused: %v", err)
|
|
||||||
}
|
|
||||||
for name, c := range map[string]struct {
|
|
||||||
on string
|
|
||||||
busy any
|
|
||||||
says string
|
|
||||||
}{
|
|
||||||
"text": {"agent", "mesh-builder busy", "it is an object"},
|
|
||||||
"no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"},
|
|
||||||
"two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"},
|
|
||||||
"a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"},
|
|
||||||
"an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`},
|
|
||||||
"on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"},
|
|
||||||
"on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"},
|
|
||||||
} {
|
|
||||||
_, err := ParseManifest(busyManifest(t, c.on, c.busy))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
||||||
t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
|
|
||||||
m, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
compose := func(with Rendering) []map[string]any {
|
|
||||||
out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
older := compose(Rendering{})
|
|
||||||
if b, sent := older[indexOfID(older, "app.agent")][BusyField]; sent {
|
|
||||||
t.Fatalf("busy was sent to an engine that does not read it: %v", b)
|
|
||||||
}
|
|
||||||
newer := compose(Rendering{ReadsBusy: true})
|
|
||||||
got, _ := newer[indexOfID(newer, "app.agent")][BusyField].(map[string]any)
|
|
||||||
if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" {
|
|
||||||
t.Fatalf("busy composed as %v", got)
|
|
||||||
}
|
|
||||||
// And the engine's own validator takes the container with it.
|
|
||||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": newer})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
|
||||||
t.Fatalf("the node-engine's validator refuses the composed busy: %v", problems)
|
|
||||||
}
|
|
||||||
if _, has := m.Resources[len(m.Resources)-1][BusyField].(map[string]any)["timeout"]; has {
|
|
||||||
t.Error("composing wrote the timeout into the catalogue's own manifest")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||||
@@ -20,103 +18,3 @@ func catalogueRoot(t *testing.T) string {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
return beside.Dir(t, "mesh-catalog")
|
return beside.Dir(t, "mesh-catalog")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEveryCatalogueManifestParses(t *testing.T) {
|
|
||||||
_, found := catalogueManifests(t)
|
|
||||||
named, routed := 0, 0
|
|
||||||
for _, r := range found {
|
|
||||||
m, err := r.manifest, r.err
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s: %v", filepath.Base(filepath.Dir(r.path)), err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, l := range m.Listens {
|
|
||||||
if l.Name != "" {
|
|
||||||
named++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for port := range RoutedPorts(m) {
|
|
||||||
_ = port
|
|
||||||
routed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed)
|
|
||||||
if named == 0 {
|
|
||||||
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no
|
|
||||||
// definition names a domain or a public address the mesh acts on, and every value that must for now
|
|
||||||
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
|
|
||||||
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
|
|
||||||
_, found := catalogueManifests(t)
|
|
||||||
var named []string
|
|
||||||
for _, r := range found {
|
|
||||||
m, err := r.manifest, r.err
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s: %v", r.path, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
named = append(named, InstallationProblems(m)...)
|
|
||||||
}
|
|
||||||
if len(named) > 0 {
|
|
||||||
t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR
|
|
||||||
// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a
|
|
||||||
// backup line by hand, every directory a container writes is declared, and every irreplaceable item is
|
|
||||||
// backed up — so a store added is a store backed up, without a list of stores to keep in step.
|
|
||||||
func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) {
|
|
||||||
_, found := catalogueManifests(t)
|
|
||||||
shelf := Shelf{}
|
|
||||||
granting := 0
|
|
||||||
for _, r := range found {
|
|
||||||
m, err := r.manifest, r.err
|
|
||||||
if err != nil {
|
|
||||||
continue // TestEveryCatalogueManifestParses says why
|
|
||||||
}
|
|
||||||
if len(m.Grants) > 0 {
|
|
||||||
granting++
|
|
||||||
}
|
|
||||||
shelf[m.Module] = m
|
|
||||||
}
|
|
||||||
for _, problem := range DataProblems(shelf) {
|
|
||||||
t.Error(problem)
|
|
||||||
}
|
|
||||||
if granting == 0 {
|
|
||||||
t.Fatal("no module in the catalogue grants a provision, so this proved nothing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every
|
|
||||||
// module's identity, on the longest machine name, fits the bound of every provision it wants. An
|
|
||||||
// overflow fails here, in the pull request that introduces it, rather than on the provider's machine
|
|
||||||
// the first time a real machine's name meets the module's (issue 263).
|
|
||||||
func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) {
|
|
||||||
_, found := catalogueManifests(t)
|
|
||||||
shelf := Shelf{}
|
|
||||||
for _, r := range found {
|
|
||||||
m, err := r.manifest, r.err
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s: %v", r.path, err)
|
|
||||||
}
|
|
||||||
shelf[m.Module] = m
|
|
||||||
}
|
|
||||||
for _, p := range IdentityProblems(shelf, DefaultLongestMachine) {
|
|
||||||
t.Error(p)
|
|
||||||
}
|
|
||||||
// The night it was found: the resolver provision bounds nothing, and the object store still 20.
|
|
||||||
if dns, ok := shelf["dnsmasq"]; ok {
|
|
||||||
if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() {
|
|
||||||
t.Errorf("the resolver provision bounds its consumers' identities: %+v", b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if store, ok := shelf["minio"]; ok {
|
|
||||||
if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 {
|
|
||||||
t.Errorf("the object store's access key is not bounded at 20: %+v", b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,44 +1,8 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// readManifest is one manifest of the catalogue as ReadTreeManifest read it.
|
// readManifest is one manifest of the catalogue as ReadTreeManifest read it.
|
||||||
type readManifest struct {
|
type readManifest struct {
|
||||||
path string // the module.json
|
path string // the module.json
|
||||||
manifest Manifest
|
manifest Manifest
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
readOnce sync.Once
|
|
||||||
readAll []readManifest
|
|
||||||
readRoot string
|
|
||||||
)
|
|
||||||
|
|
||||||
// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once
|
|
||||||
// per test binary and not once per test: the checks over the real catalogue each read and parsed every
|
|
||||||
// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is
|
|
||||||
// shared and read-only: a test that changes a manifest copies it first.
|
|
||||||
func catalogueManifests(t *testing.T) (string, []readManifest) {
|
|
||||||
t.Helper()
|
|
||||||
root := catalogueRoot(t)
|
|
||||||
readOnce.Do(func() {
|
|
||||||
readRoot = root
|
|
||||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, p := range found {
|
|
||||||
m, err := ReadTreeManifest(root, filepath.Dir(p))
|
|
||||||
readAll = append(readAll, readManifest{path: p, manifest: m, err: err})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if len(readAll) == 0 {
|
|
||||||
t.Fatalf("no manifests under %s", root)
|
|
||||||
}
|
|
||||||
return readRoot, readAll
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user