Compare commits
433
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
015304f4a3 | ||
|
|
c55672ce5e | ||
|
|
7add29cffe | ||
|
|
c1b8b9d115 | ||
|
|
8717ec76d4 | ||
|
|
e1b9f3e095 | ||
|
|
c40a79e601 | ||
|
|
b75495594a | ||
|
|
24b7740b80 | ||
|
|
aa40b107c9 | ||
|
|
d69fd304cc | ||
|
|
4a57d8c90c | ||
|
|
b8dbee28cc | ||
|
|
2bcc34a59b | ||
|
|
e282e08917 | ||
|
|
b0baa17836 | ||
|
|
ae26376a90 | ||
|
|
58f45a1b12 | ||
|
|
c1a1efa62a | ||
|
|
5b9e9d872d | ||
|
|
13e2fe14aa | ||
|
|
4bfa94dfef | ||
|
|
10852161c9 | ||
|
|
669589b76b | ||
|
|
c35cc1d9e4 | ||
|
|
3cf4fbaf3d | ||
|
|
7eb7b9fa62 | ||
|
|
ec3a65fa8d | ||
|
|
5906e18028 | ||
|
|
0871fa7cc4 | ||
|
|
cdd428a7d8 | ||
|
|
514ffc30da | ||
|
|
df44d21c82 | ||
|
|
dcbdc70919 | ||
|
|
84e50beb65 | ||
|
|
308dbb94b8 | ||
|
|
c642471c16 | ||
|
|
24cfc18383 | ||
|
|
f29b19aff2 | ||
|
|
855f4471e2 | ||
|
|
6ebad21cbe | ||
|
|
a222db8c69 | ||
|
|
66fe7c8a0d | ||
|
|
7623279efe | ||
|
|
570b66230c | ||
|
|
e5d9753368 | ||
|
|
6adaf44f65 | ||
|
|
66e89b71a2 | ||
|
|
32c5df952c | ||
|
|
4363c371c5 | ||
|
|
1b01d065ac | ||
|
|
d3ee49d6f3 | ||
|
|
ded92a8137 | ||
|
|
20614822df | ||
|
|
9740238ec9 | ||
|
|
a2313057c0 | ||
|
|
2b14f166de | ||
|
|
d8207358b0 | ||
|
|
c6ae0fc405 | ||
|
|
0128b962ab | ||
|
|
271de0108d | ||
|
|
c93feeb30f | ||
|
|
7f33c8b499 | ||
|
|
18c1735f7c | ||
|
|
2a71a8ff76 | ||
|
|
e73aae3686 | ||
|
|
3eef84d979 | ||
|
|
60358d01e9 | ||
|
|
05418b4fe5 | ||
|
|
2779f5312b | ||
|
|
e2380404a1 | ||
|
|
998d8f7370 | ||
|
|
5dc3a8e17f | ||
|
|
768f637bd0 | ||
|
|
ee0f46b78f | ||
|
|
84a14b08d5 | ||
|
|
fabfdf04f7 | ||
|
|
966093d8d0 | ||
|
|
4d47ff5ce7 | ||
|
|
8e32583089 | ||
|
|
6019c6d514 | ||
|
|
054754482c | ||
|
|
f7129fdb41 | ||
|
|
f7abd05981 | ||
|
|
514cf23aef | ||
|
|
95b509e0d2 | ||
|
|
83e135f101 | ||
|
|
30b86ea602 | ||
|
|
32a99144f6 | ||
|
|
3544d644ba | ||
|
|
a0ce37fb2c | ||
|
|
a24a1d72ad | ||
|
|
02c7f8e052 | ||
|
|
53b0e8d318 | ||
|
|
c777fbae8f | ||
|
|
98719a1bd5 | ||
|
|
72bcf20870 | ||
|
|
b47668421f | ||
|
|
2239fb253d | ||
|
|
7da4deb3f1 | ||
|
|
b461b005e9 | ||
|
|
75e3a26336 | ||
|
|
5b455cecfe | ||
|
|
faa2005538 | ||
|
|
6ea23b6731 | ||
|
|
db7cd273f0 | ||
|
|
12c80d7c2e | ||
|
|
649ed21d55 | ||
|
|
23cbf9294a | ||
|
|
03b7fcd7bb | ||
|
|
7b8f5e7805 | ||
|
|
798d73eb11 | ||
|
|
df13a83a91 | ||
|
|
61a3bd996d | ||
|
|
b8158affa7 | ||
|
|
ebb4f4af18 | ||
|
|
27a956448c | ||
|
|
0bbdae1d9d | ||
|
|
4c7056de42 | ||
|
|
ae4b86e333 | ||
|
|
8f1e5f2547 | ||
|
|
60518f532d | ||
|
|
cdd17eaae8 | ||
|
|
3293f6ddcd | ||
|
|
1ada760a64 | ||
|
|
d68e241724 | ||
|
|
be0226b624 | ||
|
|
acb720d2bf | ||
|
|
f6bda12a32 | ||
|
|
4b8b5c7329 | ||
|
|
3fa41dfe1a | ||
|
|
4f51393b72 | ||
|
|
9a12f2851f | ||
|
|
98fbc490a4 | ||
|
|
049c3b8c11 | ||
|
|
813f3cae87 | ||
|
|
77c1b50019 | ||
|
|
6bcc4eab24 | ||
|
|
692b0f0160 | ||
|
|
897a5efe7c | ||
|
|
98f4ff6163 | ||
|
|
ec4fe14457 | ||
|
|
75eb2bb534 | ||
|
|
18521ba7af | ||
|
|
a9a0f7b4e0 | ||
|
|
f5ccbb2700 | ||
|
|
0d1bbe967d | ||
|
|
ff4dc71ce4 | ||
|
|
84737f4cb2 | ||
|
|
38d666f223 | ||
|
|
5cfc4ff4a7 | ||
|
|
14a12cc7b6 | ||
|
|
44db32c31b | ||
|
|
12168ba4da | ||
|
|
72aca552e1 | ||
|
|
9ae30a7bbb | ||
|
|
e362c800a4 | ||
|
|
c7468656af | ||
|
|
598b0d8193 | ||
|
|
c27fad5c6e | ||
|
|
85826c633d | ||
|
|
9f7e898ecf | ||
|
|
74ecda8ea2 | ||
|
|
e61141f596 | ||
|
|
bc960c51db | ||
|
|
07066fe7a0 | ||
|
|
974177a074 | ||
|
|
0422893873 | ||
|
|
1d3029fa1e | ||
|
|
af66f72931 | ||
|
|
233bb23bbc | ||
|
|
e031758e4a | ||
|
|
122f4dd727 | ||
|
|
e0261cdfc1 | ||
|
|
6c1f7cc7f5 | ||
|
|
71afdf3ad2 | ||
|
|
ffa63d25a0 | ||
|
|
76c7ddfc52 | ||
|
|
39365c62c9 | ||
|
|
a4713e2d28 | ||
|
|
8bdfddab4a | ||
|
|
a4c0cee251 | ||
|
|
bb8383b065 | ||
|
|
da0f8f1cfd | ||
|
|
62b211bb60 | ||
|
|
3d1465606b | ||
|
|
1d8d726758 | ||
|
|
b0d6684d37 | ||
|
|
56eb0b792c | ||
|
|
9c15610f70 | ||
|
|
91fb323ce0 | ||
|
|
8da56ead81 | ||
|
|
7bbba2e39e | ||
|
|
08e9d15817 | ||
|
|
1b624c3ec7 | ||
|
|
7e2d947f5f | ||
|
|
1e833feb39 | ||
|
|
62cfb45789 | ||
|
|
16b8f591ed | ||
|
|
15361c661f | ||
|
|
cc3f7e8e01 | ||
|
|
d5e8458340 | ||
|
|
b133a8949d | ||
|
|
33131dcbfe | ||
|
|
2ae7c53376 | ||
|
|
4055e0094b | ||
|
|
ee71ba4dea | ||
|
|
b9c24a7b42 | ||
|
|
744bfec4a0 | ||
|
|
8bff438e5e | ||
|
|
7ecf7ba356 | ||
|
|
737bce9c94 | ||
|
|
ec191247a0 | ||
|
|
a9edd2951c | ||
|
|
015334020a | ||
|
|
a3c098a5c6 | ||
|
|
2fd165f5af | ||
|
|
db1151c773 | ||
|
|
02f78c9949 | ||
|
|
192633edb8 | ||
|
|
e5e8ff5783 | ||
|
|
c3ff18de4e | ||
|
|
3ed9abf658 | ||
|
|
552f97c919 | ||
|
|
65fbc1e6f8 | ||
|
|
cbe8528f91 | ||
|
|
5497d362dd | ||
|
|
6b32e07164 | ||
|
|
f7814853f9 | ||
|
|
58aacaad2d | ||
|
|
81672b183d | ||
|
|
76e214a8b9 | ||
|
|
acf02d52ff | ||
|
|
a7acee3eed | ||
|
|
d2645a27e9 | ||
|
|
7d39510450 | ||
|
|
f38ac87ac9 | ||
|
|
d8de0a8853 | ||
|
|
45d0348044 | ||
|
|
8c5af3e4a3 | ||
|
|
ece607e215 | ||
|
|
86a05babed | ||
|
|
1ec73915b8 | ||
|
|
f81e6147ae | ||
|
|
0a4c35f731 | ||
|
|
4f9227a186 | ||
|
|
fb8ea0e683 | ||
|
|
a6c70803ab | ||
|
|
9bd1c84e0f | ||
|
|
1f3fb52470 | ||
|
|
bb6e472939 | ||
|
|
93f33e2305 | ||
|
|
abdf85aa4f | ||
|
|
91686fdb81 | ||
|
|
d2a868f99f | ||
|
|
a143678fd8 | ||
|
|
9914459830 | ||
|
|
b7159d71a6 | ||
|
|
e0c7f36ca9 | ||
|
|
9120f94aa4 | ||
|
|
1900b40192 | ||
|
|
6cbdb3712e | ||
|
|
2a0c428ef9 | ||
|
|
a5d1f7dde4 | ||
|
|
95dd64c774 | ||
|
|
81fa009bf4 | ||
|
|
05ff90e3c1 | ||
|
|
aaffc19232 | ||
|
|
479ac71050 | ||
|
|
4f6b389a37 | ||
|
|
45f5e27526 | ||
|
|
dd8ba97989 | ||
|
|
ab22efd8b1 | ||
|
|
54aee345fc | ||
|
|
8639b373d2 | ||
|
|
5fc69444f6 | ||
|
|
2fe9d678fc | ||
|
|
58b4f8b6bc | ||
|
|
bf667a602d | ||
|
|
9a8b3870ba | ||
|
|
24c23a1d06 | ||
|
|
a548a61e86 | ||
|
|
bc28606d45 | ||
|
|
7f0eb9c51d | ||
|
|
690aa662a8 | ||
|
|
8bd0b5db15 | ||
|
|
b4fd2cc210 | ||
|
|
1a021052fe | ||
|
|
cf738cdb90 | ||
|
|
7a2af23764 | ||
|
|
daaa1962bd | ||
|
|
df878ba1f7 | ||
|
|
1e01868d92 | ||
|
|
9d04ae7edd | ||
|
|
16df7d0b7a | ||
|
|
cd1d2ff447 | ||
|
|
faf6a23d91 | ||
|
|
2f4e81f3b0 | ||
|
|
4182f8fbfb | ||
|
|
c3a2ffde67 | ||
|
|
6502ba6ad6 | ||
|
|
f6c0520f26 | ||
|
|
c912bcab6e | ||
|
|
63c0b445d7 | ||
|
|
653ff61662 | ||
|
|
a7cd0a7807 | ||
|
|
7271cf151b | ||
|
|
82d0e125b2 | ||
|
|
a4c24a1979 | ||
|
|
cff69893a3 | ||
|
|
047237c911 | ||
|
|
449cda1b00 | ||
|
|
a6e53a4858 | ||
|
|
4993f1b1d7 | ||
|
|
199f6b48b1 | ||
|
|
4636261435 | ||
|
|
4e8fdfdf04 | ||
|
|
d00926e089 | ||
|
|
bb15aab50e | ||
|
|
6a4718b712 | ||
|
|
d5adf9b72b | ||
|
|
7365890298 | ||
|
|
0862f97972 | ||
|
|
45439fd742 | ||
|
|
0184e2a5b8 | ||
|
|
50c229f905 | ||
|
|
69b845c0f7 | ||
|
|
caafd9306e | ||
|
|
0d22776b20 | ||
|
|
1803d25c3a | ||
|
|
d56c576e9a | ||
|
|
fbc6d6330f | ||
|
|
fb0cdabe25 | ||
|
|
0474a2a3cb | ||
|
|
180a7e7b80 | ||
|
|
be94d2689e | ||
|
|
b225c7bb4c | ||
|
|
1d44288514 | ||
|
|
af2d2a2c14 | ||
|
|
59d77dd457 | ||
|
|
23d66187a8 | ||
|
|
4476e58080 | ||
|
|
58de6aa10a | ||
|
|
cdd3f3fd4f | ||
|
|
21d6583b5e | ||
|
|
bb46a6ad0f | ||
|
|
f8afaefba0 | ||
|
|
1e38c42646 | ||
|
|
1241ee7041 | ||
|
|
2dc384d641 | ||
|
|
e4dbb49d2f | ||
|
|
02d9d7b053 | ||
|
|
7fc0ea5478 | ||
|
|
8be1c2ab0d | ||
|
|
ff9386dccc | ||
|
|
bba9081ca1 | ||
|
|
65cd7b4787 | ||
|
|
a78239b2aa | ||
|
|
2ac0c4686a | ||
|
|
5233f7584d | ||
|
|
8858dba25e | ||
|
|
9254180cab | ||
|
|
94f9aa298c | ||
|
|
ee9fcaba20 | ||
|
|
43f2d6020e | ||
|
|
6250b1274b | ||
|
|
7f04d12047 | ||
|
|
e88ddab241 | ||
|
|
06f70e11ee | ||
|
|
61cec66ba8 | ||
|
|
5e2fadce15 | ||
|
|
882e75c954 | ||
|
|
1a190090ab | ||
|
|
fd381567f4 | ||
|
|
1195cf4597 | ||
|
|
99285cb080 | ||
|
|
d75c66dc7e | ||
|
|
e196058f6f | ||
|
|
5e71ab6ae6 | ||
|
|
3f02bd7ff6 | ||
|
|
338797a527 | ||
|
|
2c93f79429 | ||
|
|
5f16cf507a | ||
|
|
b32a27c115 | ||
|
|
c16e625a08 | ||
|
|
1cdb4d1a29 | ||
|
|
0dfbe1a179 | ||
|
|
1bdbf47ffa | ||
|
|
16f84b53da | ||
|
|
57b553ac5a | ||
|
|
11a635796d | ||
|
|
8343c4fb36 | ||
|
|
3e3e43de52 | ||
|
|
175fb45c19 | ||
|
|
219169dbd9 | ||
|
|
b99d0b45bf | ||
|
|
f06058390c | ||
|
|
958ccbde3e | ||
|
|
43d1c2456f | ||
|
|
c46937da04 | ||
|
|
cf455bc4fe | ||
|
|
ecf142e451 | ||
|
|
fa9e4483c8 | ||
|
|
e62c32c8c3 | ||
|
|
2bfde7d659 | ||
|
|
db06feed2e | ||
|
|
4e6b756bd2 | ||
|
|
b833d09d28 | ||
|
|
81758b24a8 | ||
|
|
25ad5ce42c | ||
|
|
285395d523 | ||
|
|
b47df31834 | ||
|
|
fd05c8235d | ||
|
|
98d1f165b7 | ||
|
|
332d8f161b | ||
|
|
5088755e5e | ||
|
|
e72c682450 | ||
|
|
be5f31ce66 | ||
|
|
67db003813 | ||
|
|
e5a06cc73d | ||
|
|
20d0998cdd | ||
|
|
83f16cff8d | ||
|
|
96d0322498 | ||
|
|
b2003e7a38 | ||
|
|
3303797bfb | ||
|
|
d5dd7761c6 | ||
|
|
5f5f37b0b2 | ||
|
|
c30b86ae22 | ||
|
|
1598156752 | ||
|
|
e2b6f37168 | ||
|
|
13bdcb694b | ||
|
|
89ebd0eefe | ||
|
|
7618f661ea |
@@ -4,3 +4,5 @@
|
||||
# The placeholder `make bootstrap` moves aside while a saved image is embedded. Ignored so an
|
||||
# interrupted release build cannot commit a twenty-megabyte tar by accident.
|
||||
/internal/image/builder.tar.placeholder
|
||||
/cmd/mesh-host/mesh-host
|
||||
/cmd/mesh-bootstrap/mesh-bootstrap
|
||||
|
||||
@@ -171,26 +171,6 @@ carries. The claim is not. The foundation is four container services, and:
|
||||
So `foundation.lock` here is a real bundle with a placeholder's content. Saying that plainly
|
||||
beats shipping a host that claims a foundation it has never raised.
|
||||
|
||||
## mesh-cli's socket (novox/hq ADR 0272)
|
||||
|
||||
The engine serves `/run/nox-mesh-host/cli.sock` for `mesh-cli`, the operator's command (`internal/meshcli`). It
|
||||
reads the asking account from the kernel (`SO_PEERCRED`) and asks the controller on this node's own subject; the
|
||||
controller decides what the line may do, and the engine runs nothing it receives. One line per connection, each
|
||||
connection's deadline set the moment it is accepted, two lines at once per account and eight for the machine,
|
||||
"busy" beyond that.
|
||||
|
||||
**Only a login session can be the terminal.** The engine reads the asking process's cgroup and sends its login
|
||||
session's scope (`session-<id>.scope` under the account's own `user-<uid>.slice`, which logind's own record in
|
||||
`/run/systemd/sessions/<id>` must say is this account's, of class `user`), or nothing, read the moment the
|
||||
connection is accepted; the controller makes
|
||||
nothing else the terminal. A service running as the operator — the tool runner, a unit of the account's user
|
||||
manager — is at most an ordinary call.
|
||||
|
||||
**A process outside the machine's own namespaces is refused**, whatever its user id: the peer's mount and user
|
||||
namespace must be the engine's. So `mesh-cli` run inside a flatpak, a toolbox or distrobox container, or any other
|
||||
container — even one running as the operator's own id — is refused with the reason; run it from the machine's own
|
||||
shell.
|
||||
|
||||
## A capability is detected, never assumed
|
||||
|
||||
The reason this is the first thing built rather than a detail of it.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// mesh-compose is the node-engine's composer command (novox/hq ADR 0482 clause 4, ADR 0593 §7, issue 1621): it reads a
|
||||
// composer.Request on standard input, reads every manifest, composes the machines named in each case and writes one
|
||||
// composer.Verdict on standard output. Built at a commit of the node-engine's repository, it answers what that
|
||||
// commit's composition answers, so a group that changes the node-engine's composition is judged by its own next code.
|
||||
//
|
||||
// mesh-compose < request.json > verdict.json
|
||||
// mesh-compose -request request.json > verdict.json
|
||||
// mesh-compose -catalogue <dir> [-node <name>]... [-depth resolve|compose] [-declarations] [< request.json]
|
||||
//
|
||||
// -catalogue reads <dir>/modules/*/module.json and a module.json at <dir>'s root as the first case's manifests (a
|
||||
// checkout or `git archive` of the catalogue repository at a ref: the command fetches nothing). -node adds a machine to
|
||||
// compose; -declarations answers each machine's declared paths with their owners and modes.
|
||||
//
|
||||
// It opens no network connection, bus, store or file beyond its standard input, the -request file and the -catalogue
|
||||
// directory, and reads
|
||||
// no environment. Exit codes: 0 the verdict is written (a machine that does not compose is a verdict, not an error);
|
||||
// 1 the request cannot be read or run, said on standard error; 2 the flags are wrong.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/composer"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
type nodes []string
|
||||
|
||||
func (n *nodes) String() string { return strings.Join(*n, ",") }
|
||||
func (n *nodes) Set(v string) error { *n = append(*n, v); return nil }
|
||||
|
||||
// run is the command: its arguments, standard input, output and error; it answers the exit code.
|
||||
func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("mesh-compose", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
catalogueDir := fs.String("catalogue", "", "a catalogue checkout: <dir>/modules/*/module.json are the first case's manifests")
|
||||
var machines nodes
|
||||
fs.Var(&machines, "node", "a machine to compose (repeatable)")
|
||||
depth := fs.String("depth", "", "resolve or compose (default: the request's, else compose)")
|
||||
declarations := fs.Bool("declarations", false, "answer each composed machine's declared paths, owners and modes")
|
||||
requestFile := fs.String("request", "", "read the request from this file instead of standard input (a run step that hands no standard input)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
fmt.Fprintf(stderr, "mesh-compose: unexpected arguments %q\n", fs.Args())
|
||||
return 2
|
||||
}
|
||||
var raw []byte
|
||||
var err error
|
||||
if *requestFile != "" {
|
||||
raw, err = os.ReadFile(*requestFile)
|
||||
} else {
|
||||
raw, err = io.ReadAll(stdin)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose: the request cannot be read:", err)
|
||||
return 1
|
||||
}
|
||||
var req composer.Request
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
if *catalogueDir == "" {
|
||||
fmt.Fprintln(stderr, "mesh-compose: no request on standard input and no -catalogue")
|
||||
return 1
|
||||
}
|
||||
req = composer.Request{V: composer.Version}
|
||||
} else if req, err = composer.Decode(raw); err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose:", err)
|
||||
return 1
|
||||
}
|
||||
if *catalogueDir != "" {
|
||||
read, err := readCatalogue(*catalogueDir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose:", err)
|
||||
return 1
|
||||
}
|
||||
if len(req.Cases) == 0 {
|
||||
req.Cases = []composer.Case{{Name: "catalogue", Manifests: map[string]composer.Manifest{}}}
|
||||
}
|
||||
if req.Cases[0].Manifests == nil {
|
||||
req.Cases[0].Manifests = map[string]composer.Manifest{}
|
||||
}
|
||||
for name, m := range read {
|
||||
if _, given := req.Cases[0].Manifests[name]; !given {
|
||||
req.Cases[0].Manifests[name] = m
|
||||
}
|
||||
}
|
||||
// No list of predefined keys is read: the composer judges no manifest (novox/hq ADR 0578 §5).
|
||||
}
|
||||
req.Machines = append(req.Machines, machines...)
|
||||
if *depth != "" {
|
||||
req.Depth = *depth
|
||||
}
|
||||
if *declarations {
|
||||
req.Declarations = true
|
||||
}
|
||||
v, err := composer.Run(req)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose:", err)
|
||||
return 1
|
||||
}
|
||||
enc := json.NewEncoder(stdout)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose: the verdict cannot be written:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// readCatalogue reads every module.json of a catalogue checkout by its module's name: the root's and each of
|
||||
// modules/*/. A manifest whose name cannot be read is kept under its directory's name, so the reader's refusal is said.
|
||||
func readCatalogue(dir string) (map[string]composer.Manifest, error) {
|
||||
paths, err := filepath.Glob(filepath.Join(dir, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "module.json")); err == nil {
|
||||
paths = append(paths, filepath.Join(dir, "module.json"))
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, fmt.Errorf("the catalogue %s holds no module.json", dir)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
out := map[string]composer.Manifest{}
|
||||
for _, p := range paths {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var probe struct {
|
||||
Module string `json:"module"`
|
||||
}
|
||||
name := filepath.Base(filepath.Dir(p))
|
||||
if json.Unmarshal(raw, &probe) == nil && probe.Module != "" {
|
||||
name = probe.Module
|
||||
}
|
||||
if _, twice := out[name]; twice {
|
||||
return nil, errors.New("the catalogue " + dir + " defines " + name + " twice")
|
||||
}
|
||||
out[name] = composer.Manifest{Raw: raw}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"github.com/novox/mesh-host/internal/buckets/bucketstest"
|
||||
"go/build"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/beside"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/composer"
|
||||
)
|
||||
|
||||
// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name.
|
||||
func captured(t *testing.T) map[string]composer.Manifest {
|
||||
t.Helper()
|
||||
read, err := readCatalogue(filepath.Dir(beside.Catalogue(t)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(read) < 100 {
|
||||
t.Fatalf("the captured catalogue holds %d manifests, fewer than the catalogue's", len(read))
|
||||
}
|
||||
return read
|
||||
}
|
||||
|
||||
// meshOf is a mesh of three machines over the captured catalogue, each assigned real modules, the input the assigner
|
||||
// composes with (nodes and their assignments, holdings from the claims): anchor runs the bus and the store, laptop a
|
||||
// desktop's modules, ace a module whose requirement is provided elsewhere.
|
||||
func meshOf() compose.Input {
|
||||
return compose.Input{Nodes: []compose.Node{
|
||||
{Name: "anchor", Assigned: []string{"nats", "postgres"}},
|
||||
{Name: "laptop", Assigned: []string{"sudo", "zsh"}},
|
||||
{Name: "ace", Assigned: []string{"mesh-reads", "nats"}},
|
||||
}}
|
||||
}
|
||||
|
||||
func request(t *testing.T, depth string) composer.Request {
|
||||
t.Helper()
|
||||
r := composer.Request{V: composer.Version, Depth: depth, HoldingsFromClaims: true,
|
||||
Machines: []string{"anchor", "laptop", "ace"},
|
||||
Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}}
|
||||
return withInput(t, r, meshOf())
|
||||
}
|
||||
|
||||
// withInput is r with in as its input, as a caller puts it on the wire: the request's shape carries the input as JSON.
|
||||
func withInput(t *testing.T, r composer.Request, in compose.Input) composer.Request {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r.Input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func runJSON(t *testing.T, args []string, in []byte) (int, composer.Verdict, string) {
|
||||
t.Helper()
|
||||
var out, errOut bytes.Buffer
|
||||
code := run(args, bytes.NewReader(in), &out, &errOut)
|
||||
var v composer.Verdict
|
||||
if code == 0 {
|
||||
if err := json.Unmarshal(out.Bytes(), &v); err != nil {
|
||||
t.Fatalf("the verdict is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
}
|
||||
return code, v, errOut.String()
|
||||
}
|
||||
|
||||
func encode(t *testing.T, r composer.Request) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// 1621/AC1: the command reads the plain input and the manifests on standard input, validates every manifest, composes
|
||||
// the machines named and writes one verdict: per machine composes or the reason, per manifest accepted or the field
|
||||
// refused.
|
||||
func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) {
|
||||
r := request(t, composer.DepthResolve)
|
||||
r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)}
|
||||
r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)}
|
||||
r.Cases[0].Verdicts = bucketstest.Raw(map[string]meshcatalog.VerdictsByDigestValue{"refused": {V: 1, Module: "refused", Judge: "src2:j", State: meshcatalogclient.VerdictRefused, Problems: []string{"it is wrong"}}})
|
||||
code, v, stderr := runJSON(t, nil, encode(t, r))
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, stderr)
|
||||
}
|
||||
if len(v.Cases) != 1 || v.Cases[0].Name != "trunk" || v.SDK == "" {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
c := v.Cases[0]
|
||||
// ADR 0578 §5, §6: the composer judges nothing; it sets an unknown field aside and names it, and holds a manifest
|
||||
// its verdict refuses, named in the indexer's words.
|
||||
if got := c.Manifests["later"]; !got.Accepted || !strings.Contains(got.SetAside, `"shares"`) {
|
||||
t.Errorf("the manifest with an unknown field reads %+v, want accepted naming the field set aside", got)
|
||||
}
|
||||
if got := c.Manifests["refused"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of refused: it is wrong") {
|
||||
t.Errorf("the refused manifest reads %+v, want refused in the verdict's words", got)
|
||||
}
|
||||
if got := c.Manifests["nats"]; !got.Accepted {
|
||||
t.Errorf("nats reads %+v, want accepted", got)
|
||||
}
|
||||
if len(c.Machines) != 3 {
|
||||
t.Fatalf("machines = %v, want the three named", c.Machines)
|
||||
}
|
||||
for name, m := range c.Machines {
|
||||
if !m.Composes && m.Reason == "" {
|
||||
t.Errorf("%s does not compose and says no reason", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for
|
||||
// machine and manifest for manifest, what the linked reader, resolution and composition answer, at both depths.
|
||||
func TestTheCommandAnswersWhatTheLinkedCompositionAnswers(t *testing.T) {
|
||||
for _, depth := range []string{composer.DepthResolve, composer.DepthCompose} {
|
||||
r := request(t, depth)
|
||||
code, v, stderr := runJSON(t, nil, encode(t, r))
|
||||
if code != 0 {
|
||||
t.Fatalf("%s: exit %d: %s", depth, code, stderr)
|
||||
}
|
||||
in := meshOf()
|
||||
in.Catalogue = map[string]catalogue.Manifest{}
|
||||
for name, m := range r.Cases[0].Manifests {
|
||||
// A request without a list of predefined keys reads a fallback as the indexer does (Request.Predefined).
|
||||
parsed, err := catalogue.Decode(m.Raw)
|
||||
got := v.Cases[0].Manifests[name]
|
||||
if (err == nil) != got.Accepted || (err != nil && err.Error() != got.Refused) {
|
||||
t.Errorf("%s: the manifest of %s: linked %v, command %+v", depth, name, err, got)
|
||||
}
|
||||
if err == nil {
|
||||
in.Catalogue[name] = parsed
|
||||
}
|
||||
}
|
||||
in.Holdings = composer.HoldingsOf(in.Catalogue, in.Nodes)
|
||||
composed := 0
|
||||
for _, node := range r.Machines {
|
||||
got := v.Cases[0].Machines[node]
|
||||
var want composer.Machine
|
||||
if depth == composer.DepthResolve {
|
||||
if _, err := compose.Resolve(in, node); err != nil {
|
||||
want = composer.Machine{Reason: err.Error()}
|
||||
} else {
|
||||
want = composer.Machine{Composes: true}
|
||||
}
|
||||
} else {
|
||||
d, problems, err := compose.Compose(in, node)
|
||||
switch {
|
||||
case err != nil:
|
||||
want = composer.Machine{Reason: err.Error()}
|
||||
default:
|
||||
want = composer.Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
|
||||
if len(problems) > 0 {
|
||||
want.Reason = "the node-engine's validator refuses the declaration"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s: %s: command %+v, linked %+v", depth, node, got, want)
|
||||
}
|
||||
if got.Composes {
|
||||
composed++
|
||||
} else {
|
||||
t.Logf("%s: %s does not compose: %s %v", depth, node, got.Reason, got.Problems)
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d of %d machines compose", depth, composed, len(r.Machines))
|
||||
}
|
||||
}
|
||||
|
||||
// 1621 note 6: a person composes a catalogue checkout (repository@ref checked out or archived: the command fetches
|
||||
// nothing) for a node and reads its declaration: each path with its kind, owner and mode.
|
||||
func TestACatalogueCheckoutIsComposedForANode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "modules", "demo"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"module": "demo", "version": "1", "resources": [{"id": "state", "type": "directory", ` +
|
||||
`"path": "/var/lib/demo", "mode": "0750", "owner": "root"}]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := encode(t, withInput(t, composer.Request{V: composer.Version}, compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}}))
|
||||
code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, stderr)
|
||||
}
|
||||
m := v.Cases[0].Machines["laptop"]
|
||||
want := []composer.Path{{Path: "/var/lib/demo", Kind: "directory", Owner: "root", Mode: "0750", Module: ""}}
|
||||
if !m.Composes || len(m.Declared) != 1 || m.Declared[0].Path != want[0].Path || m.Declared[0].Mode != "0750" ||
|
||||
m.Declared[0].Owner != "root" || m.Declared[0].Kind != "directory" {
|
||||
t.Fatalf("laptop = %+v, want it composed declaring %+v", m, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A run step hands the command no standard input: the request is read from the file -request names, and a file that is
|
||||
// not there is a request it cannot read.
|
||||
func TestTheRequestIsReadFromAFile(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "request.json")
|
||||
r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "only", Manifests: map[string]composer.Manifest{
|
||||
"demo": {Raw: json.RawMessage(`{"module":"demo","version":"1"}`)}}}}}
|
||||
if err := os.WriteFile(file, encode(t, r), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, v, stderr := runJSON(t, []string{"-request", file}, nil)
|
||||
if code != 0 || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted {
|
||||
t.Fatalf("exit %d (%s), verdict %+v", code, stderr, v)
|
||||
}
|
||||
if code, _, stderr := runJSON(t, []string{"-request", file + ".gone"}, nil); code != 1 || !strings.Contains(stderr, "cannot be read") {
|
||||
t.Errorf("a request file that is not there: exit %d, %q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// The exit codes: 1 for a request that cannot be read or run, 2 for wrong flags, each said on standard error.
|
||||
func TestTheExitCodesSayWhatFailed(t *testing.T) {
|
||||
unfilled := encode(t, composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "c", Manifests: map[string]composer.Manifest{"x": {Digest: "abc"}}}}})
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
args []string
|
||||
in string
|
||||
code int
|
||||
says string
|
||||
}{
|
||||
{"not JSON", nil, "{", 1, "not JSON"},
|
||||
{"another version", nil, `{"v":2}`, 1, "version 2"},
|
||||
{"a digest left unfilled", nil, string(unfilled), 1, "digest"},
|
||||
{"an unknown depth", nil, `{"v":1,"depth":"deep"}`, 1, "depth"},
|
||||
{"nothing to read", nil, "", 1, "no request"},
|
||||
{"a wrong flag", []string{"-nope"}, "", 2, "nope"},
|
||||
{"an argument", []string{"extra"}, "", 2, "unexpected"},
|
||||
} {
|
||||
var out, errOut bytes.Buffer
|
||||
if got := run(c.args, strings.NewReader(c.in), &out, &errOut); got != c.code || !strings.Contains(errOut.String(), c.says) {
|
||||
t.Errorf("%s: exit %d saying %q, want %d saying %q", c.name, got, errOut.String(), c.code, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ownPackages is every package of this repository the command imports, directly or not, with the packages each
|
||||
// imports: what this repository adds to the composition; mesh-sdk's packages are judged in mesh-sdk.
|
||||
func ownPackages(t *testing.T) map[string][]string {
|
||||
t.Helper()
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
root := filepath.Join(filepath.Dir(file), "..", "..")
|
||||
const module = "github.com/novox/mesh-host"
|
||||
out := map[string][]string{}
|
||||
var walk func(path string)
|
||||
walk = func(path string) {
|
||||
if _, done := out[path]; done {
|
||||
return
|
||||
}
|
||||
rel := strings.TrimPrefix(strings.TrimPrefix(path, module), "/")
|
||||
pkg, err := build.Default.ImportDir(filepath.Join(root, rel), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
out[path] = pkg.Imports
|
||||
for _, imp := range pkg.Imports {
|
||||
if strings.HasPrefix(imp, module+"/") {
|
||||
walk(imp)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(composer.Package)
|
||||
return out
|
||||
}
|
||||
|
||||
// 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard
|
||||
// library, mesh-sdk, the contracts modules' pure packages (seats' types, clients' codecs), the delivery SDK's composer (the command's own request and verdict types), the builder's builds package (a build's value and its keys), the private network's overlay package (mesh-wireguard's own pure package: names, graph and
|
||||
// generator, no bus) and this repository's own packages, and none that reaches a bus, a store, a network service or
|
||||
// another process.
|
||||
func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) {
|
||||
forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"}
|
||||
own := ownPackages(t)
|
||||
if len(own) < 4 {
|
||||
t.Fatalf("the command reaches %d packages of this repository; the walk found too few to judge", len(own))
|
||||
}
|
||||
for path, imports := range own {
|
||||
for _, imp := range imports {
|
||||
std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".")
|
||||
if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/sdk/composer") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") {
|
||||
t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp)
|
||||
}
|
||||
for _, f := range forbidden {
|
||||
if imp == f {
|
||||
t.Errorf("%s imports %s", path, imp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1621/AC2: run with an empty network namespace, the command still writes its verdict: it needs no network. The test
|
||||
// runs its own binary again under `unshare --net` as an unprivileged user's namespace; where the kernel or the sandbox
|
||||
// allows no new namespace (a build module's run step is already one with no network), it says so and skips.
|
||||
func TestTheCommandRunsWithNoNetwork(t *testing.T) {
|
||||
if os.Getenv("MESH_COMPOSE_HELPER") == "1" {
|
||||
os.Exit(run(nil, os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
unshare, err := exec.LookPath("unshare")
|
||||
if err != nil {
|
||||
t.Skip("no unshare on this machine, so no empty network namespace can be made here")
|
||||
}
|
||||
if out, err := exec.Command(unshare, "--net", "--map-root-user", "true").CombinedOutput(); err != nil {
|
||||
t.Skipf("this machine makes no unprivileged network namespace (%v: %s); a run step's own has no network", err, out)
|
||||
}
|
||||
r := request(t, composer.DepthResolve)
|
||||
cmd := exec.Command(unshare, "--net", "--map-root-user", os.Args[0], "-test.run=^TestTheCommandRunsWithNoNetwork$")
|
||||
cmd.Env = []string{"MESH_COMPOSE_HELPER=1"}
|
||||
cmd.Stdin = bytes.NewReader(encode(t, r))
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errOut
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("the command in an empty network namespace: %v: %s", err, errOut.String())
|
||||
}
|
||||
var v composer.Verdict
|
||||
if err := json.Unmarshal(out.Bytes(), &v); err != nil || len(v.Cases) != 1 || len(v.Cases[0].Machines) != 3 {
|
||||
t.Fatalf("no verdict from the command with no network: %v\n%s", err, out.String())
|
||||
}
|
||||
names := make([]string, 0, len(v.Cases[0].Machines))
|
||||
for n := range v.Cases[0].Machines {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
t.Logf("composed with no network: %v", names)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// appliedStore keeps every composition the agent applied, one file per module (the node's own as `node.json`), in the
|
||||
// directory `applied` beside the node's state (agent.Env.Applied, issue 774). It is the machine's own record of what runs:
|
||||
// read at the agent's start before anything is watched, so the node never composes its own as if a module it runs ran
|
||||
// nothing because the bucket no longer keeps that composition or the kept declaration is retired. Files are 0600 in a
|
||||
// 0700 directory: a composition carries values sealed to this machine.
|
||||
type appliedStore struct{ dir string }
|
||||
|
||||
func appliedStoreFor(statePath string) appliedStore {
|
||||
return appliedStore{dir: filepath.Join(filepath.Dir(statePath), "applied")}
|
||||
}
|
||||
|
||||
// appliedName is a module name the store takes as a file name: a catalogue module's or the node's own, never a path.
|
||||
var appliedName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
||||
|
||||
func (s appliedStore) path(module string) (string, error) {
|
||||
if !appliedName.MatchString(module) || strings.Contains(module, "..") {
|
||||
return "", fmt.Errorf("%q is not a module name the applied store takes", module)
|
||||
}
|
||||
return filepath.Join(s.dir, module+".json"), nil
|
||||
}
|
||||
|
||||
// holds says whether the store keeps the module's composition.
|
||||
func (s appliedStore) holds(module string) bool {
|
||||
path, err := s.path(module)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s appliedStore) Load() (map[string][]byte, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]byte{}
|
||||
var errs []error
|
||||
for _, e := range entries {
|
||||
module, ok := strings.CutSuffix(e.Name(), ".json")
|
||||
if !ok || e.IsDir() || !appliedName.MatchString(module) {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
out[module] = raw
|
||||
}
|
||||
return out, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (s appliedStore) Keep(module string, raw []byte) error {
|
||||
path, err := s.path(module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if was, err := os.ReadFile(path); err == nil && string(was) == string(raw) {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(s.dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(s.dir, "."+module+".*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
func (s appliedStore) Drop(module string) error {
|
||||
path, err := s.path(module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// An apply runs in a context the bus's loss cannot end: the link's context ends when the bus goes, and the bus may go
|
||||
// because of the apply itself (it replaces the bus's own container), which then must finish (issue 1945, live on
|
||||
// 2026-10-25 at 11:23: novox's node-engine removed the bus container, its link's context ended, and the docker run that
|
||||
// would have made the new one was never made).
|
||||
func TestAnApplyOutlivesTheLinksContext(t *testing.T) {
|
||||
type key struct{}
|
||||
link, cancel := context.WithCancel(context.WithValue(context.Background(), key{}, "v"))
|
||||
ctx, done := applyContext(link)
|
||||
defer done()
|
||||
cancel()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatalf("the apply's context ended with the link's: %v", ctx.Err())
|
||||
}
|
||||
if ctx.Value(key{}) != "v" {
|
||||
t.Fatal("the apply's context lost the link's values")
|
||||
}
|
||||
}
|
||||
|
||||
// An apply is bounded all the same: a step that hangs (a docker call that never returns) cannot hold the node agent for
|
||||
// ever (the review of issue 1945).
|
||||
func TestAnApplyIsBounded(t *testing.T) {
|
||||
t.Run("the marks of the haves never hold the pass", marksOfTheHavesNeverHoldThePass)
|
||||
ctx, done := applyContext(context.Background())
|
||||
defer done()
|
||||
at, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
t.Fatal("an apply has no bound")
|
||||
}
|
||||
if left := time.Until(at); left > applyBound || left < applyBound-time.Minute {
|
||||
t.Fatalf("the bound is %s away, want about %s", left, applyBound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// applyAndKeep applies a whole declaration the way the carried bundle is applied, and says what came of it as the facts
|
||||
// the node says: what the tests of the apply's steps read. The node-engine applies no whole declaration on its own now
|
||||
// (novox/hq ADR 0539 §9); this is the test's way to put a machine in a state.
|
||||
func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.Declared, sched *apply.Scheduler,
|
||||
say link.Announce) link.Report {
|
||||
declared, err := declaration.Parse(raw)
|
||||
if err != nil {
|
||||
return link.Report{Refused: err.Error()}
|
||||
}
|
||||
got := applyModuleHeld(ctx, opts, declared, nil, sched, say)
|
||||
if got.Refused != "" {
|
||||
return link.Report{Refused: got.Refused}
|
||||
}
|
||||
held := map[string]bool{}
|
||||
for _, h := range got.Updated.Held {
|
||||
held[h.ID] = true
|
||||
}
|
||||
report := factsOf(ctx, opts, declared, got.Updated, got.Outcome, held)
|
||||
if signed != nil {
|
||||
if err := store.SaveDeclared(store.DeclaredPath(opts.state), *signed); err != nil {
|
||||
report.Failed = map[string]string{"keeping the declaration": err.Error()}
|
||||
}
|
||||
}
|
||||
if got.Err != nil {
|
||||
report.Failed = map[string]string{"apply": got.Err.Error()}
|
||||
}
|
||||
report.Failed = withUnanswered(report.Failed, got.Probe)
|
||||
report.Unapplied = unapplied(declared, got.Outcome, got.Err)
|
||||
return report
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// authorityPath is where this engine keeps the mesh's own certificate as it last applied it (public): beside the
|
||||
// certificate issued for the machine's name, read into its record (novox/hq ADR 0555 §3).
|
||||
func authorityPath(state string) string { return filepath.Join(filepath.Dir(state), "authority.crt") }
|
||||
|
||||
// keepAuthority keeps the mesh's own certificate a composition carries (the resource a module's certificate.authority
|
||||
// asks for, catalogue.AuthorityID), so the record says the authority this machine holds. A composition carrying none
|
||||
// changes nothing.
|
||||
func keepAuthority(state string, c engcomp.Composed) error {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) != nil || r.Content == "" || !strings.HasSuffix(r.ID, "."+catalogue.AuthorityID()) {
|
||||
continue
|
||||
}
|
||||
path := authorityPath(state)
|
||||
if was, err := os.ReadFile(path); err == nil && string(was) == r.Content {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(r.Content), 0o644)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
)
|
||||
|
||||
// Issue 1126 (ADR 0555 §3): the mesh's own certificate a composition carries is kept for the machine's record, and the
|
||||
// record's keys read it; a composition carrying none changes nothing. And (L3 s6, s7; ADR 0550 §2) the record says what
|
||||
// the node trusts a mesh signature from, and the node keeps the certificate the vault signed for it.
|
||||
func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
|
||||
state := filepath.Join(t.TempDir(), "state", "state.json")
|
||||
res := func(id, content string) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]any{"id": id, "type": "file", "path": "/etc/x", "content": content})
|
||||
return raw
|
||||
}
|
||||
if err := keepAuthority(state, engcomp.Composed{Resources: []json.RawMessage{res("web.conf", "x")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(authorityPath(state)); !os.IsNotExist(err) {
|
||||
t.Fatalf("a composition with no authority kept one: %v", err)
|
||||
}
|
||||
if err := keepAuthority(state, engcomp.Composed{Resources: []json.RawMessage{res("ca-trust.certificate-authority", "CA-1")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := recordKeys(state, nil)(); got.Authority != "CA-1" {
|
||||
t.Errorf("the record's keys read the authority %q", got.Authority)
|
||||
}
|
||||
|
||||
// Issue 1126, A6 (ADR 0550 §2; the controller ends by its unassignment): a node enrolled with the controller's key
|
||||
// trusts it never, whatever the lease says; before the vault's signers are read it trusts nothing, once read the
|
||||
// vault's key alone, and the record names the vault's alone. Each change tells the record's writer.
|
||||
controllerPub, controller, _ := ed25519.GenerateKey(nil)
|
||||
vaultPub, vault, _ := ed25519.GenerateKey(nil)
|
||||
trust := newMeshTrust()
|
||||
told := 0
|
||||
trust.onChange(func() { told++ })
|
||||
keys := recordKeys(state, trust.Trusts)
|
||||
ctrlFP, vaultFP := meshvaultclient.SignerFingerprint(controllerPub), meshvaultclient.SignerFingerprint(vaultPub)
|
||||
enrol, _ := meshvaultclient.SignContext(meshvaultclient.KindEnrolment)
|
||||
msg := []byte("a reply")
|
||||
if got := keys().Trusts; len(got) != 0 {
|
||||
t.Errorf("before the signers are read the record trusts %v", got)
|
||||
}
|
||||
if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(controller, enrol, msg)); err == nil {
|
||||
t.Error("a controller-signed message is accepted")
|
||||
}
|
||||
if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(vault, enrol, msg)); err == nil {
|
||||
t.Error("a vault-signed message is accepted before the vault's key is read")
|
||||
}
|
||||
vaultCA := testAuthority(t, vaultPub, vault)
|
||||
keyValue, _ := json.Marshal(meshvaultclient.NewSignerKey(vaultPub, time.Now()))
|
||||
fp, _ := meshvaultclient.AuthorityFingerprint(vaultCA)
|
||||
authValue, _ := json.Marshal(meshvault.SignersByAuthorityValue{Certificate: vaultCA, Fingerprint: fp, Made: time.Now()})
|
||||
trust.heardSigner(meshvault.SignersByKeyKey(), keyValue, false, func(string) {})
|
||||
trust.heardSigner(meshvault.SignersByAuthorityKey(), authValue, false, func(string) {})
|
||||
if s, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(vault, enrol, msg)); err != nil || s.Name != meshvaultclient.SignerVault {
|
||||
t.Errorf("a vault-signed message on a node that read signers: %+v, %v", s, err)
|
||||
}
|
||||
if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(controller, enrol, msg)); err == nil {
|
||||
t.Error("a controller-signed message is accepted once the vault's key is read")
|
||||
}
|
||||
if got := keys().Trusts; strings.Contains(strings.Join(got, " "), ctrlFP) || !meshvaultclient.TrustsKey(got, meshvaultclient.SignerVault, vaultFP) ||
|
||||
trust.VaultAuthority() != vaultCA || told != 2 {
|
||||
t.Errorf("after the signers are read the record trusts %v (told %d)", got, told)
|
||||
}
|
||||
// A signers' key naming another fingerprint is not trusted.
|
||||
bad, _ := json.Marshal(meshvault.SignersByKeyValue{Key: base64.StdEncoding.EncodeToString(controllerPub), Fingerprint: vaultFP})
|
||||
trust.heardSigner(meshvault.SignersByKeyKey(), bad, false, func(string) {})
|
||||
if !meshvaultclient.TrustsKey(keys().Trusts, meshvaultclient.SignerVault, vaultFP) {
|
||||
t.Error("a signers' key that does not check replaced the vault's")
|
||||
}
|
||||
|
||||
// L3 s7: the certificate the vault signs for this node's internal name and serving key is kept where the record reads
|
||||
// it, and the record is written again; one signed by another authority is refused and nothing is kept.
|
||||
serving, err := identity.GenerateServingKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := identity.WriteServingKey(identity.ServingKeyPath(state), serving); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
servingPub, _ := base64.StdEncoding.DecodeString(serving.Public)
|
||||
signedBy := func(ca string, key ed25519.PrivateKey) string {
|
||||
parent, _ := meshvaultclient.ParseCertificate(ca)
|
||||
der, err := x509.CreateCertificate(nil, meshvaultclient.NodeCertificateTemplate(overlay.InternalName("n1"), servingPub, time.Now().Add(-time.Hour)),
|
||||
parent, ed25519.PublicKey(servingPub), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
}
|
||||
var asked []any
|
||||
answer := signedBy(vaultCA, vault)
|
||||
ask := func(_ context.Context, verb string, args any) (json.RawMessage, error) {
|
||||
asked = append(asked, verb, args)
|
||||
return json.Marshal(meshvault.SignAnswer{Kind: meshvaultclient.KindNodeCertificate, Node: "n1", Certificate: answer})
|
||||
}
|
||||
written := 0
|
||||
if err := certifyThisNode(context.Background(), state, "n1", trust, ask, func() { written++ }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := recordKeys(state, nil)(); got.Certificate != answer || written != 1 || len(asked) != 2 || asked[0] != meshvault.VerbSign {
|
||||
t.Errorf("the vault's certificate: kept %v, written %d, asked %v", got.Certificate == answer, written, asked)
|
||||
}
|
||||
answer = signedBy(testAuthority(t, controllerPub, controller), controller)
|
||||
if err := certifyThisNode(context.Background(), state, "n1", trust, ask, func() { written++ }); err == nil || written != 1 {
|
||||
t.Errorf("a certificate of another authority was kept: %v", err)
|
||||
}
|
||||
|
||||
// Issue 774: what the engine applied is kept beside the state too, one file per module, 0600 in a 0700 directory,
|
||||
// written by a rename so a crash leaves the one before; a name that is no module's is refused, never a path.
|
||||
t.Run("the compositions the engine applied are kept beside the state", func(t *testing.T) {
|
||||
applied := appliedStoreFor(filepath.Join(t.TempDir(), "state", "state.json"))
|
||||
if m, err := applied.Load(); err != nil || len(m) != 0 || applied.holds("node") {
|
||||
t.Fatalf("an empty store loads %v, %v", m, err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
module string
|
||||
takes bool
|
||||
}{{"node", true}, {"nats", true}, {"mesh-delivery-view", true}, {"../state", false}, {"a/b", false}, {"..", false}, {"", false}} {
|
||||
if err := applied.Keep(tc.module, []byte(`{"v":1}`)); (err == nil) != tc.takes {
|
||||
t.Errorf("keeping %q: %v, want taken %v", tc.module, err, tc.takes)
|
||||
}
|
||||
}
|
||||
if err := applied.Keep("node", []byte(`{"v":2}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(applied.dir)
|
||||
dirInfo, _ := os.Stat(applied.dir)
|
||||
fileInfo, _ := os.Stat(filepath.Join(applied.dir, "node.json"))
|
||||
if len(entries) != 3 || dirInfo.Mode().Perm() != 0o700 || fileInfo.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("the store holds %d files (want 3, no temporary one left), directory %v, file %v", len(entries), dirInfo.Mode(), fileInfo.Mode())
|
||||
}
|
||||
if err := applied.Drop("nats"); err != nil || applied.Drop("nats") != nil || applied.holds("nats") {
|
||||
t.Fatalf("a dropped module is still held, or dropping twice fails: %v", err)
|
||||
}
|
||||
if m, err := applied.Load(); err != nil || string(m["node"]) != `{"v":2}` || len(m) != 2 {
|
||||
t.Fatalf("the store loads %v, %v", m, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// testAuthority is a self-signed authority certificate for key, as the vault makes its own.
|
||||
func testAuthority(t *testing.T, pub ed25519.PublicKey, key ed25519.PrivateKey) string {
|
||||
t.Helper()
|
||||
tpl := meshvaultclient.AuthorityTemplate(pub, time.Now().Add(-time.Hour))
|
||||
der, err := x509.CreateCertificate(nil, tpl, tpl, pub, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
func backupEvent(node, module string, ok bool, at time.Time, why string) []byte {
|
||||
raw, _ := json.Marshal(map[string]any{"node": node, "module": module, "ok": ok, "at": at, "error": why})
|
||||
return raw
|
||||
}
|
||||
|
||||
// A backup is done when the holder says so for this node and module, held on its words when it says it failed, and held
|
||||
// when it says nothing within the bound; another node's or module's event, or one from before the ask, is not the answer
|
||||
// (R6-A A5, novox/hq ADR 0429 §14).
|
||||
func TestABackupEndsWhenItsEventSaysSo(t *testing.T) {
|
||||
since := time.Now()
|
||||
run := func(within time.Duration, events ...[]byte) (done bool, words string) {
|
||||
ch := make(chan []byte, len(events))
|
||||
for _, e := range events {
|
||||
ch <- e
|
||||
}
|
||||
got := awaitBackup(context.Background(), ch, "n1", "db", since, within)
|
||||
return got.Done, got.Words
|
||||
}
|
||||
if done, _ := run(time.Second,
|
||||
backupEvent("n2", "db", true, since, ""), backupEvent("n1", "web", true, since, ""),
|
||||
backupEvent("n1", "db", false, since.Add(-time.Hour), "an old night"),
|
||||
backupEvent("n1", "db", true, since.Add(time.Second), "")); !done {
|
||||
t.Error("the event of this node and module was not the answer")
|
||||
}
|
||||
if done, words := run(time.Second, backupEvent("n1", "db", false, since, "the repository is locked")); done ||
|
||||
!strings.Contains(words, "repository is locked") {
|
||||
t.Errorf("a failed backup: done=%v words=%q", done, words)
|
||||
}
|
||||
if done, words := run(50 * time.Millisecond); done || !strings.Contains(words, "did not say it was done") {
|
||||
t.Errorf("silence within the bound: done=%v words=%q", done, words)
|
||||
}
|
||||
// The node agent stopping while it waits is no failure of the backup: said as interrupted, so its next start backs
|
||||
// the module up again (postgres on novox, 2026-10-31).
|
||||
stopped, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if got := awaitBackup(stopped, make(chan []byte), "n1", "db", since, time.Second); got.Done || !got.Interrupted ||
|
||||
got.Words != agent.BackupInterrupted {
|
||||
t.Errorf("a stopped node agent: %+v", got)
|
||||
}
|
||||
if got := awaitBackup(context.Background(), make(chan []byte), "n1", "db", since, 10*time.Millisecond); got.Interrupted {
|
||||
t.Errorf("silence within the bound taken as interrupted: %+v", got)
|
||||
}
|
||||
|
||||
// The ask that starts it (sweep 2026-10-30): one that never reached the holder (nothing on this machine answers it
|
||||
// while the tool runner restarts) is asked again within its bound, and past it fails saying so; one that may have
|
||||
// reached it is never asked twice.
|
||||
notAsked := fmt.Errorf("node-backup.now could not be asked: %w: nothing on this machine answers "+
|
||||
"mesh.seat.node-backup.tool.now.ace", link.ErrNotAsked)
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
answers []error
|
||||
within time.Duration
|
||||
asked int
|
||||
words string
|
||||
}{
|
||||
{"heard after the tool runner is back", []error{notAsked, notAsked, nil}, time.Second, 3, ""},
|
||||
// Asked again until the bound, however slow the machine: at least twice, and fewer than it would answer.
|
||||
{"never heard within the bound", []error{notAsked, notAsked, notAsked, notAsked, notAsked, nil}, 25 * time.Millisecond,
|
||||
0, "times over"},
|
||||
{"a timeout may have started it", []error{errors.New("no answer from x: context deadline exceeded"), nil},
|
||||
time.Second, 1, "deadline exceeded"},
|
||||
{"a refusal in words", []error{errors.New("restic is not on this machine"), nil}, time.Second, 1, "not on this"},
|
||||
} {
|
||||
asked := 0
|
||||
_, err := askUntilHeard(context.Background(), func(context.Context) (json.RawMessage, error) {
|
||||
asked++
|
||||
return json.RawMessage(`{}`), row.answers[asked-1]
|
||||
}, row.within, func(int) time.Duration { return 10 * time.Millisecond })
|
||||
if row.asked == 0 && (asked < 2 || asked >= len(row.answers)) || row.asked != 0 && asked != row.asked || (row.words == "") != (err == nil) || err != nil && !strings.Contains(err.Error(), row.words) {
|
||||
t.Errorf("%s: asked %d times, %v", row.name, asked, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The events of the same kind (ENG-11): the status view's settled is told to the agent by the module that settled, on
|
||||
// whichever node; a body with no module, or no JSON, tells nothing; the end of the link ends the hearing.
|
||||
told := &settledRecorder{}
|
||||
events := make(chan []byte, 4)
|
||||
for _, body := range []string{`{"node":"n2","module":"distribution"}`, `{"node":"n1"}`, `not json`, `{"module":"gitea"}`} {
|
||||
events <- []byte(body)
|
||||
}
|
||||
linked, end := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); listen(linked, events, func() settledTold { return told }, func(string) {}) }()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for len(told.heard()) < 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
end()
|
||||
<-done
|
||||
if got := strings.Join(told.heard(), ","); got != "distribution,gitea" {
|
||||
t.Errorf("the agent was told %q settled, want distribution,gitea", got)
|
||||
}
|
||||
// And the images an apply could not pull are said with the holder the declaration names beside each, none for a public
|
||||
// one (ENG-8, ENG-10).
|
||||
d, err := declaration.ParseFileTrusted([]byte(`{"declaration":1,"resources":[` +
|
||||
`{"id":"a.first","type":"container","name":"first","image":"reg/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","image-seat":"mesh-artifact-store","image-holder":"distribution"},` +
|
||||
`{"id":"a.second","type":"container","name":"second","image":"public/y@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pull := func(image string) error {
|
||||
return &apply.Error{Resource: "x", Err: &apply.PullError{Image: image, Err: errors.New("manifest unknown")}}
|
||||
}
|
||||
if got := pullsOf(d, pull("reg/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); len(got) != 1 || got[0].Seat != "mesh-artifact-store" || got[0].Holder != "distribution" {
|
||||
t.Errorf("a store's image is said as %+v", got)
|
||||
}
|
||||
if got := pullsOf(d, pull("public/y@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); len(got) != 1 || got[0].Holder != "" || got[0].Seat != "" {
|
||||
t.Errorf("a public image is said as %+v", got)
|
||||
}
|
||||
if got := pullsOf(d, errors.New("another failure")); len(got) != 0 {
|
||||
t.Errorf("a failure that is no pull is said as %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// settledRecorder is the agent a hearing tells.
|
||||
type settledRecorder struct {
|
||||
mu sync.Mutex
|
||||
list []string
|
||||
}
|
||||
|
||||
func (r *settledRecorder) Settled(holder string) {
|
||||
r.mu.Lock()
|
||||
r.list = append(r.list, holder)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *settledRecorder) heard() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.list...)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module that declares nothing to back up on this machine is not backed up before it moves, and so never held for a
|
||||
// backup that cannot run (issue 1945, live on 2026-10-25: redis and searxng on ace were held: "redis declares nothing to
|
||||
// back up on this machine"). What node-backup.backed-up answers decides it.
|
||||
func TestAModuleWithNothingToBackUpIsNotBackedUp(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
said string
|
||||
nothing bool
|
||||
}{
|
||||
{`[{"module":"postgres","runs":1,"paths":["/var/lib/postgres"]}]`, true},
|
||||
{`[{"module":"redis","runs":0,"paths":null,"data":[{"item":"cache"}]}]`, true},
|
||||
{`[]`, true},
|
||||
{`[{"module":"redis","runs":1,"paths":["/var/lib/redis"]}]`, false},
|
||||
} {
|
||||
nothing, ok := declaresNothingToBackUp(json.RawMessage(c.said), "redis")
|
||||
if !ok || nothing != c.nothing {
|
||||
t.Errorf("%s: nothing %v (read %v), want %v", c.said, nothing, ok, c.nothing)
|
||||
}
|
||||
}
|
||||
if _, ok := declaresNothingToBackUp(json.RawMessage(`{"error":"x"}`), "redis"); ok {
|
||||
t.Error("an answer that is not the list was read")
|
||||
}
|
||||
}
|
||||
@@ -5,62 +5,30 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
// A container waiting because it said busy is news when it starts waiting and when it is replaced, never on
|
||||
// its output alone (novox/hq ADR 0335): the walk waiting on this machine starts its gate at the report that
|
||||
// says it replaced, and a reconcile is otherwise silent.
|
||||
func TestABusyWaitIsSaidWhenItStartsAndWhenItEnds(t *testing.T) {
|
||||
quiet := link.Report{Outward: []string{"eth0"}}
|
||||
waiting := link.Report{Outward: []string{"eth0"}, Busy: []link.Busy{{ID: "build-agent.server",
|
||||
Container: "mesh-build-agent", State: "waiting", Said: "building a"}}}
|
||||
still := link.Report{Outward: []string{"eth0"}, Busy: []link.Busy{{ID: "build-agent.server",
|
||||
Container: "mesh-build-agent", State: "waiting", Said: "building b, a minute on"}}}
|
||||
replaced := link.Report{Outward: []string{"eth0"}, Busy: []link.Busy{{ID: "build-agent.server",
|
||||
Container: "mesh-build-agent", State: "replaced-when-idle"}}}
|
||||
if adoptionFingerprint(waiting) == adoptionFingerprint(quiet) {
|
||||
t.Error("a container starting to wait is not news")
|
||||
}
|
||||
if adoptionFingerprint(still) != adoptionFingerprint(waiting) {
|
||||
t.Error("a wait whose output changed reads as news on every ask")
|
||||
}
|
||||
if adoptionFingerprint(replaced) == adoptionFingerprint(waiting) {
|
||||
t.Error("a container replaced after it waited is not news")
|
||||
}
|
||||
if !worthSaying(link.Report{Busy: replaced.Busy}) {
|
||||
t.Error("a replacement after a wait is not worth saying")
|
||||
}
|
||||
}
|
||||
|
||||
// While a container waits, a pass is asked for every so often, not at the reconcile's five minutes; with
|
||||
// nothing waiting, none (novox/hq ADR 0335 §2).
|
||||
func TestAWaitIsAskedAgainSoonerThanTheReconcile(t *testing.T) {
|
||||
if BusyAskEvery >= ReconcileEvery || BusyAskEvery > time.Minute {
|
||||
// While a container waits, a pass is asked for every so often, not at some long interval; with nothing waiting, none
|
||||
// (novox/hq ADR 0335 §2, ADR 0360 §11).
|
||||
func TestAWaitIsAskedAgainSoon(t *testing.T) {
|
||||
if BusyAskEvery > time.Minute {
|
||||
t.Fatalf("a waiting container is asked every %s", BusyAskEvery)
|
||||
}
|
||||
var reconciles atomic.Int32
|
||||
queue := &link.Queue{Reconcile: func(context.Context) (link.Report, bool) {
|
||||
reconciles.Add(1)
|
||||
return link.Report{}, false
|
||||
}}
|
||||
var asked atomic.Int32
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go queue.Run(ctx)
|
||||
busyWaiting.Store(false)
|
||||
t.Cleanup(func() { busyWaiting.Store(false) })
|
||||
go busyPasses(ctx, queue, 10*time.Millisecond)
|
||||
go busyPasses(ctx, func() { asked.Add(1) }, 10*time.Millisecond)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if n := reconciles.Load(); n != 0 {
|
||||
if n := asked.Load(); n != 0 {
|
||||
t.Fatalf("%d passes were asked with nothing waiting", n)
|
||||
}
|
||||
busyWaiting.Store(true)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for reconciles.Load() == 0 && time.Now().Before(deadline) {
|
||||
for asked.Load() == 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if reconciles.Load() == 0 {
|
||||
if asked.Load() == 0 {
|
||||
t.Fatal("no pass was asked while a container waits")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A composition that names a file the provisioner fills without saying it is filled would have this node-engine write
|
||||
// that file empty: its apply is refused, loudly, before anything is written (issue 1945, live on 2026-10-25 at 01:22,
|
||||
// when ace, g14 and shanks wrote claude-code's, node-env's and power's files empty).
|
||||
func TestAFileItCannotProduceIsNeverWrittenEmpty(t *testing.T) {
|
||||
unit := json.RawMessage(`{"id":"power.after-boot-unit","type":"file","path":"/etc/systemd/system/mesh-power-after-boot.service","mode":"0644","owner-at-runtime":"owned"}`)
|
||||
c := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{unit}}
|
||||
d, err := declarationOf(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := refuseEmptyWrites(d); err == nil || !strings.Contains(err.Error(), "power.after-boot-unit") {
|
||||
t.Fatalf("a file the provisioner fills, not filled here: %v", err)
|
||||
}
|
||||
c.Filled = []string{"/etc/systemd/system/mesh-power-after-boot.service"}
|
||||
if d, _ = declarationOf(c); refuseEmptyWrites(d) != nil {
|
||||
t.Fatalf("a filled file was refused: %v", refuseEmptyWrites(d))
|
||||
}
|
||||
plain := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"x.f","type":"file","path":"/etc/x","mode":"0644","content":"x"}`)}}
|
||||
if d, _ = declarationOf(plain); refuseEmptyWrites(d) != nil {
|
||||
t.Fatalf("a file with its content was refused: %v", refuseEmptyWrites(d))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"github.com/novox/mesh-host/internal/accounts"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/selfcompose"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// The node seat node-engine's judgements on this machine (novox/hq ADR 0563): what a hand-over and a library's take
|
||||
// would set and what the disk holds now, and whether this machine is root-free.
|
||||
|
||||
// diskOf is what is on disk at a directory, as an act compares it when the operator's yes arrives: its owner and
|
||||
// group, its mode, and which file it is.
|
||||
func diskOf(info os.FileInfo) string {
|
||||
owner := "unknown"
|
||||
if uid, gid, ok := apply.OwnerOf(info); ok {
|
||||
owner = fmt.Sprintf("%d:%d", uid, gid)
|
||||
}
|
||||
which := ""
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
which = fmt.Sprintf(" %v:%v", st.Dev, st.Ino)
|
||||
}
|
||||
return fmt.Sprintf("%s %o%s", owner, info.Mode().Perm(), which)
|
||||
}
|
||||
|
||||
// handOverJudgeFrom judges a hand-over through the node seat as the controller's ask is judged (judgeFound): the
|
||||
// declared owner and mode it will set, and the disk as it is.
|
||||
func handOverJudgeFrom(statePath string) func(string) (link.Judged, error) {
|
||||
return func(path string) (link.Judged, error) {
|
||||
found, info, err := judgeFound(statePath, path)
|
||||
if err != nil {
|
||||
return link.Judged{}, err
|
||||
}
|
||||
return link.Judged{Subject: path, Path: path, Sets: orUnsaid(found.Declared), Disk: diskOf(info),
|
||||
Words: "A directory of " + moduleOf(found.ID) + " is used as found: the mesh left its owner and mode."}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func moduleOf(id string) string {
|
||||
if at := strings.LastIndex(id, "."); at > 0 {
|
||||
return id[:at]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// accessJudgeFrom judges the take act on a library through the node seat (ADR 0328 §7, ADR 0563 §4): declared on this
|
||||
// machine, its home, and its root holding data the mesh has not taken; what the take sets, and the disk as it is.
|
||||
func accessJudgeFrom(statePath string) func(string, string) (link.Judged, error) {
|
||||
return func(act, access string) (link.Judged, error) {
|
||||
d, err := declaredNow(statePath, nil)
|
||||
if errors.Is(err, store.ErrNothingDeclared) {
|
||||
return link.Judged{}, errors.New("this node keeps no declaration, so it is declared no library")
|
||||
}
|
||||
if err != nil {
|
||||
return link.Judged{}, fmt.Errorf("what runs on this node does not read: %w", err)
|
||||
}
|
||||
for _, l := range d.Libraries {
|
||||
if apply.LibraryAccess(l.Library) != access {
|
||||
continue
|
||||
}
|
||||
if !l.Home {
|
||||
return link.Judged{}, fmt.Errorf("this machine is not the home of the library %s, and a library is "+
|
||||
"taken on its home", l.Library)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), lookBound)
|
||||
defer cancel()
|
||||
if v := apply.JudgeLibrary(ctx, l); v.Kind != apply.VerdictWaitKeep {
|
||||
return link.Judged{}, fmt.Errorf("the library %s does not wait for the operator's word to be kept "+
|
||||
"(it is judged %s), so there is nothing to take", l.Library, v.Kind)
|
||||
}
|
||||
info, err := os.Lstat(l.Root)
|
||||
if err != nil {
|
||||
return link.Judged{}, fmt.Errorf("the library %s's root cannot be read: %v", l.Library, err)
|
||||
}
|
||||
return link.Judged{Subject: access, Path: filepath.Clean(l.Root),
|
||||
Sets: "the group " + l.Group + ", group write and setgid on everything under its root",
|
||||
Disk: diskOf(info), Words: "The library " + l.Library + " holds data the mesh has not taken."}, nil
|
||||
}
|
||||
return link.Judged{}, fmt.Errorf("the access %s names no library declared on this machine", access)
|
||||
}
|
||||
}
|
||||
|
||||
// askTakes asks the take of every library the look found waiting for the operator's word, once per library (ADR 0563
|
||||
// §4): asked by the engine itself, never a second time while one is open or after one was asked; access-act asks again
|
||||
// by hand.
|
||||
func askTakes(acts *link.HandActs, waiting []string, say func(string)) {
|
||||
if acts == nil {
|
||||
return
|
||||
}
|
||||
for _, access := range waiting {
|
||||
if acts.AskedFor(link.ActAccess, access) {
|
||||
continue
|
||||
}
|
||||
if _, err := acts.AskAccess(link.EngineSeat+"-"+acts.Node, link.ActTake, access,
|
||||
"its root holds data outside its group", true); err != nil && say != nil {
|
||||
say("the take of " + access + " could not be asked: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lastAccounts is the newest look at every account (the health loop's), which root-free judges from.
|
||||
var lastAccounts atomic.Pointer[accounts.Statement]
|
||||
|
||||
// rootFreshFor is how old the verdict root-free judges from may be (ADR 0563 §6, the controller's verdictFreshFor).
|
||||
const rootFreshFor = 15 * time.Minute
|
||||
|
||||
// judgeRootFree is this machine's root-free (ADR 0259 §8, ADR 0563 §6): free only on the engine's own verdict, at most
|
||||
// rootFreshFor old, that every account declared never to become root without a person cannot, at least one such
|
||||
// account declared, and the login shell's execute not served here. Anything else is no pass, and says why.
|
||||
func judgeRootFree(node string, st *accounts.Statement, now time.Time, served bool, servedWhy string) link.RootVerdict {
|
||||
v := link.RootVerdict{Machine: node, Judged: now.UTC()}
|
||||
var not []string
|
||||
confined := ""
|
||||
switch {
|
||||
case st == nil:
|
||||
not = append(not, "this machine's engine has judged no account yet")
|
||||
case now.Sub(st.At) > rootFreshFor:
|
||||
not = append(not, fmt.Sprintf("the engine's newest judgement of its accounts was made at %s, more than %d "+
|
||||
"minutes ago, and a verdict that old is not a verdict on now", st.At.UTC().Format(time.RFC3339),
|
||||
int(rootFreshFor.Minutes())))
|
||||
default:
|
||||
var named []string
|
||||
for _, a := range st.Accounts {
|
||||
if !a.Root {
|
||||
continue
|
||||
}
|
||||
named = append(named, a.Name)
|
||||
if a.State != accounts.Healthy {
|
||||
not = append(not, "the account "+a.Name+" "+orNoneKnownWord(a.Reason))
|
||||
}
|
||||
}
|
||||
if len(named) == 0 {
|
||||
not = append(not, "no account here is declared never to become root, so agents run as the operator's "+
|
||||
"account, which may become root")
|
||||
} else if len(not) == 0 {
|
||||
confined = "the account " + strings.Join(named, ", ") + " cannot become root without a person (judged " +
|
||||
st.At.UTC().Format(time.RFC3339) + ")"
|
||||
}
|
||||
}
|
||||
if served {
|
||||
not = append(not, "the login shell runs any command an agent gives it as the machine's runtime account, which "+
|
||||
"can become root ("+servedWhy+")")
|
||||
}
|
||||
if len(not) > 0 {
|
||||
v.Why = strings.Join(not, "; ")
|
||||
return v
|
||||
}
|
||||
v.Free, v.Why = true, confined+"; the login shell's execute is not served here"
|
||||
return v
|
||||
}
|
||||
|
||||
func orNoneKnownWord(s string) string {
|
||||
if s == "" {
|
||||
return "is not judged"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// settingFileSuffix is the resource the login shell's holder renders its setting execute into (ADR 0268: zsh's
|
||||
// tools-conf, `execute=serve` or `execute=withhold`), read beside the bus's discovery.
|
||||
const settingFileSuffix = ".tools-conf"
|
||||
|
||||
// executeServedHere is whether the login shell's execute is served on this machine (ADR 0268), failing closed: served
|
||||
// while the bus's discovery hears it answered here or could not be asked, or while the holder's setting file applied
|
||||
// here says anything but withhold or cannot be read. why says which.
|
||||
func executeServedHere(statePath string, heard func() (bool, error)) (bool, string) {
|
||||
var why []string
|
||||
if heard == nil {
|
||||
why = append(why, "the bus could not be asked whether execute is answered here")
|
||||
} else if h, err := heard(); err != nil {
|
||||
why = append(why, "the bus could not be asked whether execute is answered here: "+err.Error())
|
||||
} else if h {
|
||||
why = append(why, "the bus hears execute answered here")
|
||||
}
|
||||
known, err := store.Load(statePath)
|
||||
if err != nil {
|
||||
why = append(why, "what this machine applied could not be read: "+err.Error())
|
||||
}
|
||||
for _, r := range known.Resources {
|
||||
if r.Type != "file" || !strings.HasSuffix(r.ID, settingFileSuffix) {
|
||||
continue
|
||||
}
|
||||
if value, err := settingIn(r.Target); err != nil {
|
||||
why = append(why, moduleOf(r.ID)+"'s setting execute cannot be read: "+err.Error())
|
||||
} else if value != "withhold" {
|
||||
why = append(why, moduleOf(r.ID)+"'s setting execute here is "+value)
|
||||
}
|
||||
}
|
||||
return len(why) > 0, strings.Join(why, "; ")
|
||||
}
|
||||
|
||||
// settingIn is the value of execute in a setting file, "" when it says none.
|
||||
func settingIn(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
value := ""
|
||||
lines := bufio.NewScanner(f)
|
||||
for lines.Scan() {
|
||||
t := strings.TrimSpace(lines.Text())
|
||||
if key, v, ok := strings.Cut(t, "="); ok && !strings.HasPrefix(t, "#") && strings.TrimSpace(key) == "execute" {
|
||||
value = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if value == "" {
|
||||
value = "not said"
|
||||
}
|
||||
return value, lines.Err()
|
||||
}
|
||||
|
||||
// keepWaiting is every library a look found waiting for the operator's word to keep it (ADR 0328 §8).
|
||||
func keepWaiting(vs []link.ResourceHealth) []string {
|
||||
var out []string
|
||||
for _, v := range vs {
|
||||
for _, w := range v.AccessWaits {
|
||||
if w.Reason == link.WaitKeep {
|
||||
out = append(out, w.Access)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// engineActs is the node seat this engine holds (ADR 0563): judged and performed by the same code as the controller's
|
||||
// signed asks, its asks to the operator over the session's link, its acts kept beside the state.
|
||||
func engineActs(opts options, node string, queue *link.Session, search *accounts.SetuidCache) *link.HandActs {
|
||||
state := link.BusState{Session: queue, Node: node}
|
||||
h := &link.HandActs{Node: node, State: state,
|
||||
JudgeHandOver: handOverJudgeFrom(opts.state), HandOver: handOverFrom(opts.state),
|
||||
JudgeAccess: accessJudgeFrom(opts.state), AccessAct: accessActFrom(opts.state, opts.timeout),
|
||||
JudgeDirectories: judgeDirectoriesFrom(opts.state), SetuidSearch: queue.SetuidSearch, Searching: search.Running,
|
||||
RootFree: func() link.RootVerdict {
|
||||
served, why := executeServedHere(opts.state, func() (bool, error) {
|
||||
return queue.HeardServed("node-login-shell", "execute")
|
||||
})
|
||||
return judgeRootFree(node, lastAccounts.Load(), time.Now(), served, why)
|
||||
},
|
||||
ManifestReads: queue.StatedReads, Say: queue.SayEngine}
|
||||
asker := occ.EngineAsker(node)
|
||||
h.Asker = &occ.Client{Module: asker, State: state, Publish: queue.PublishAsk,
|
||||
Record: func(id string) (*stdio.StateEntry, error) { return queue.RouterRecord(asker + "." + id) }}
|
||||
return h
|
||||
}
|
||||
|
||||
// reconcileEvery is how often the engine catches up its open asks from the router's records (a warrant heard while no
|
||||
// link was open, an ask past its expiry): besides each link's opening.
|
||||
const reconcileEvery = 5 * time.Minute
|
||||
|
||||
func reconcileActs(ctx context.Context, h *link.HandActs) {
|
||||
t := time.NewTicker(reconcileEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
h.Reconcile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compositionReadFor bounds one read of the verb composition: the revisions of one key from the bucket.
|
||||
const compositionReadFor = 20 * time.Second
|
||||
|
||||
// compositionFrom answers the verb composition from the node agent that runs now (issue 774); refused in words while
|
||||
// none does.
|
||||
func compositionFrom(current *agentNow) func(module, digest string, diff bool) (any, error) {
|
||||
return func(module, digest string, diff bool) (any, error) {
|
||||
ag := current.get()
|
||||
if ag == nil {
|
||||
return nil, errors.New("this engine's node agent has not started, so it holds no composition yet")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), compositionReadFor)
|
||||
defer cancel()
|
||||
return ag.Composition(ctx, module, digest, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// composeReadFor bounds the verb compose's wait for its pass: a pass that waits on a seat longer says what it waits on.
|
||||
const composeReadFor = 2 * time.Minute
|
||||
|
||||
// composeFrom answers the verb compose (issue 774): a pass of the composer that runs now, and with module that module's
|
||||
// apply tried again by the node agent; refused in words while the node composes nothing of its own.
|
||||
func composeFrom(current *agentNow, composer *atomic.Pointer[selfcompose.Composer]) func(module string) (any, error) {
|
||||
return func(module string) (any, error) {
|
||||
c := composer.Load()
|
||||
if c == nil {
|
||||
return nil, errors.New("this node composes none of its own modules now (it applies the controller's " +
|
||||
"compositions, or its node agent is starting), so there is no pass to make")
|
||||
}
|
||||
out := map[string]any{}
|
||||
if module != "" {
|
||||
// Only a failed apply that is not held is tried again; what runs is never taken away (review round 1: the
|
||||
// verb has no warrant, ADR 0563 §2, and a hold for a failed backup is the operator's to release, ADR 0568 §4).
|
||||
ag := current.get()
|
||||
if ag == nil {
|
||||
return nil, errors.New("this engine's node agent has not started, so no apply is tried again")
|
||||
}
|
||||
if err := ag.RetryFailed(module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["retried"] = module
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), composeReadFor)
|
||||
defer cancel()
|
||||
pass, err := c.ComposeNow(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out["pass"] = pass
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// packagesReadFor bounds the verb packages' reads and its write.
|
||||
const packagesReadFor = 20 * time.Second
|
||||
|
||||
// packagesFrom answers the verb packages (novox/hq ADR 0565 §6, §15): this node's key of the engine's state packages;
|
||||
// with write, the key written again from what the
|
||||
// composer that runs now handed, refused in words while the node composes none of its own.
|
||||
func packagesFrom(node string, session *link.Session, composer *atomic.Pointer[selfcompose.Composer]) func(write bool) (any, error) {
|
||||
return func(write bool) (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), packagesReadFor)
|
||||
defer cancel()
|
||||
var wrote *bool
|
||||
if write {
|
||||
c := composer.Load()
|
||||
if c == nil {
|
||||
return nil, errors.New("this node composes none of its own modules now (its node agent is starting), " +
|
||||
"so it has no packages of its own to write")
|
||||
}
|
||||
w, err := c.WritePackages(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("this node's key of %s is not written: %w", link.PackagesBucket, err)
|
||||
}
|
||||
wrote = &w
|
||||
}
|
||||
key, keyErr := link.EnginePackages{Session: session}.Get(ctx, node)
|
||||
v := link.PackagesAnswer(node, key, keyErr)
|
||||
v.Wrote = wrote
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
// composeCandidateFrom answers the verb compose-candidate (novox/hq ADR 0593 §7): the check's candidate composed on this
|
||||
// node in shadow by the run's shadow, applying and writing nothing, within the shadow's own bound (shorter than
|
||||
// nec.AnswerBound, selfcompose candidateWithin). The answer is
|
||||
// the seat's shape (nodeengine.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet
|
||||
// answers unready, which the checker takes as no answer.
|
||||
func composeCandidateFrom(node string, shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) {
|
||||
return func(candidate string, input bool) (any, error) {
|
||||
ask := nodeengine.ComposeCandidateInput{Candidate: candidate, Input: input}
|
||||
if _, err := nec.EncodeAsk(ask); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a nodeengine.ComposeCandidateAnswer
|
||||
if s := shadow.Load(); s == nil {
|
||||
// Unready, never an error the checker reads words of: its agent is starting (ADR 0593 §9).
|
||||
a = nodeengine.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: nec.Unready,
|
||||
Refused: "this node's shadow does not run now (its node agent is starting), so it composes no candidate"}
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nec.AnswerBound)
|
||||
defer cancel()
|
||||
a = s.ComposeCandidate(ctx, ask)
|
||||
}
|
||||
raw, err := nec.EncodeAnswer(a)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("this node's answer to %s does not fit the seat's shape: %w", candidate, err)
|
||||
}
|
||||
return json.RawMessage(raw), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
|
||||
"os"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
// enrolThroughSeat joins this machine through the seat mesh-node-enroller (novox/hq ADR 0587 §6, issue 774): it redeems the token
|
||||
// with the keys it made, verifies the vault's signed answer against the key the token carries before it keeps anything, keeps
|
||||
// its membership and its login, dials as the login node-logins names and confirms. A bus that does not serve the seat to this
|
||||
// token (link.ErrNoSeat) is said as the error: nothing was sent or kept.
|
||||
func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, node string, mine identity.Identity,
|
||||
sealing identity.SealingKey, serving identity.ServingKey, reported map[string]any, found *link.Tunnel, identityPath string) error {
|
||||
|
||||
req := meshnodeenrollerclient.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(mine.Public), OverlayKey: mine.Overlay.Public,
|
||||
SealingKey: sealing.Public, ServingKey: serving.Public, Profile: reported}
|
||||
if found != nil {
|
||||
var err error
|
||||
if req.Tunnel, err = json.Marshal(found); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
req.Proof = base64.StdEncoding.EncodeToString(mine.Sign(meshnodeenrollerclient.RedeemProof(node, req.PublicKey, req.OverlayKey, req.SealingKey, req.ServingKey)))
|
||||
to := link.Approach{Address: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS}
|
||||
membership := link.Membership{Node: node, Broker: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS}
|
||||
j := link.Joining{Node: node, Signer: token.SignerKey(),
|
||||
Redeem: func(ctx context.Context) (meshnodeenrollerclient.RedeemAnswer, error) {
|
||||
return link.Redeem(ctx, to, node, token.Secret, req, opts.timeout)
|
||||
},
|
||||
Open: func(a meshnodeenrollerclient.RedeemAnswer) (string, error) {
|
||||
value, err := sealing.Open(a.Account)
|
||||
return string(value), err
|
||||
},
|
||||
Keep: func(a meshnodeenrollerclient.RedeemAnswer, password string) error {
|
||||
// The mesh's name for this node wins over what the machine called itself, as on the controller's path.
|
||||
mine.Node = a.Node
|
||||
mine.Membership = identity.Membership{Broker: token.Broker, Fingerprint: token.Fingerprint, Signer: token.Signer,
|
||||
Password: password, Transport: link.OnNATS}
|
||||
if err := writeLogins(loginsFile(opts.state), link.LoginsState{Current: a.Login, Values: map[string]string{a.Login: password}}); err != nil {
|
||||
return fmt.Errorf("cannot keep the login %s: %w", a.Login, err)
|
||||
}
|
||||
if err := identity.Save(identityPath, mine); err != nil {
|
||||
return err
|
||||
}
|
||||
if !mine.Membership.Joined() {
|
||||
return errors.New("the answer did not say how to reach the mesh again, so the identity could not be used after a restart")
|
||||
}
|
||||
return keepJoinedKeys(opts, mine, sealing, serving)
|
||||
},
|
||||
Dial: func(ctx context.Context, login, password string) (link.Confirming, error) {
|
||||
return link.DialConfirming(ctx, membership, login, password, opts.timeout)
|
||||
},
|
||||
}
|
||||
answer, err := j.Run(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("\nenrolled as %s through the seat mesh-node-enroller, dialled as %s and confirmed\n", answer.Node, answer.Login)
|
||||
fmt.Printf(" identity %s\n", identityPath)
|
||||
fmt.Println(" nothing is applied until the assignment of this node names a module")
|
||||
return nil
|
||||
}
|
||||
|
||||
// keepJoinedKeys writes the private halves of the keys this machine made, after its identity: an identity naming a key that is
|
||||
// not there looks joined and cannot come up, so the identity is written first and a failure here is said plainly.
|
||||
func keepJoinedKeys(opts options, mine identity.Identity, sealing identity.SealingKey, serving identity.ServingKey) error {
|
||||
if err := os.WriteFile(identity.OverlayKeyPath(opts.state), []byte(mine.Overlay.Private+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write this node's overlay key: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(identity.SealingKeyPath(opts.state), []byte(sealing.Private+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write this node's sealing key: %w", err)
|
||||
}
|
||||
if err := identity.WriteServingKey(identity.ServingKeyPath(opts.state), serving); err != nil {
|
||||
return fmt.Errorf("cannot write this node's serving key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// **A declaration from an older lease epoch is refused before anything is applied** (novox/hq to-be
|
||||
// 45 §6, ADR 0227 rule 2). The controller that sent it lost its lease and goes on sending; this node
|
||||
// applied the holder's. Refused on the host's own apply path — the one the queue's worker runs — before
|
||||
// the machine is read or touched, with a report naming what was refused and what is held, and the
|
||||
// kept declaration left as it was.
|
||||
func TestADeclarationFromAnOlderEpochIsRefusedBeforeAnythingIsApplied(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := options{state: filepath.Join(dir, "state.json")}
|
||||
_, private, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(dir, "a.conf")
|
||||
declared := func(epoch, sequence int64) store.Declared {
|
||||
body, err := json.Marshal(map[string]any{"declaration": 1, "epoch": epoch, "sequence": sequence,
|
||||
"resources": []any{map[string]any{"id": "a", "type": "file", "path": target, "content": "x\n"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store.Declared{Declaration: body, Signature: ed25519.Sign(private, body)}
|
||||
}
|
||||
held := declared(57, 3)
|
||||
if err := store.SaveDeclared(store.DeclaredPath(opts.state), held); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stale := declared(41, 12)
|
||||
report := applyAndKeep(context.Background(), opts, stale.Declaration, &stale, nil, nil)
|
||||
if report.OlderThan == nil || *report.OlderThan != (link.Order{Epoch: 57, Sequence: 3}) ||
|
||||
report.Order != (link.Order{Epoch: 41, Sequence: 12}) || report.Declared != digestOf(stale.Declaration) ||
|
||||
report.Refused == "" {
|
||||
t.Fatalf("the refusal does not name what was refused and what is held: %+v", report)
|
||||
}
|
||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("the refused declaration touched the machine")
|
||||
}
|
||||
kept, err := store.ReadDeclared(store.DeclaredPath(opts.state))
|
||||
if err != nil || string(kept.Declaration) != string(held.Declaration) {
|
||||
t.Fatal("the refused declaration replaced what this node kept")
|
||||
}
|
||||
}
|
||||
|
||||
// The queue's counts are kept beside the state and read back by the next host; unreadable is an
|
||||
// error, never zero.
|
||||
func TestTheNumbersSurviveTheHost(t *testing.T) {
|
||||
state := filepath.Join(t.TempDir(), "state.json")
|
||||
if sequence, refused, err := numbersBeside(state).Read(); err != nil || sequence != 0 || refused != 0 {
|
||||
t.Fatalf("a node that never reported read %d, %d, %v", sequence, refused, err)
|
||||
}
|
||||
if err := numbersBeside(state).Save(41, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sequence, refused, err := numbersBeside(state).Read(); err != nil || sequence != 41 || refused != 2 {
|
||||
t.Fatalf("read back %d, %d, %v", sequence, refused, err)
|
||||
}
|
||||
if err := os.WriteFile(store.NumbersPath(state), []byte(`{"report_sequence":`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := numbersBeside(state).Read(); err == nil {
|
||||
t.Fatal("unreadable numbers were read as zero")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -9,13 +12,20 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
// aFilesLink is a link that serves objects by name.
|
||||
type aFilesLink struct{ objects map[string][]byte }
|
||||
// aFilesLink is a link that serves objects by name, or fails every read with err.
|
||||
type aFilesLink struct {
|
||||
objects map[string][]byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (a aFilesLink) FileObject(_ context.Context, name string, _ int64) ([]byte, error) {
|
||||
if a.err != nil {
|
||||
return nil, a.err
|
||||
}
|
||||
if b, ok := a.objects[name]; ok {
|
||||
return b, nil
|
||||
}
|
||||
@@ -40,7 +50,7 @@ func TestADeclarationWithAFileByDigestAppliesFromTheObject(t *testing.T) {
|
||||
|
||||
bad := append([]byte(nil), body...)
|
||||
bad[10] ^= 1
|
||||
ctx := link.WithFiles(context.Background(), aFilesLink{map[string][]byte{name: bad}})
|
||||
ctx := link.WithFiles(context.Background(), aFilesLink{objects: map[string][]byte{name: bad}})
|
||||
report := applyAndKeep(ctx, opts, raw, nil, nil, nil)
|
||||
if report.Refused == "" || !strings.Contains(report.Refused, "m.big") {
|
||||
t.Errorf("1695/AC2: a differing object did not refuse the declaration: %+v", report)
|
||||
@@ -48,8 +58,24 @@ func TestADeclarationWithAFileByDigestAppliesFromTheObject(t *testing.T) {
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
t.Error("1695/AC2: a file was written from an object that differs")
|
||||
}
|
||||
if failedWith(report.Refused).Transient {
|
||||
t.Errorf("an object that differs is a real failure, and was taken as the bus not answering: %s", report.Refused)
|
||||
}
|
||||
|
||||
ctx = link.WithFiles(context.Background(), aFilesLink{map[string][]byte{name: body}})
|
||||
// The bus answers no JetStream request while it reloads its accounts (2026-10-25 23:04:24, mesh-vault on the bus
|
||||
// node): the failure is transient, so the agent tries it again, and the detail kept is its cause, the tail.
|
||||
noResponders := fmt.Errorf("fetching the object 2bd98ebd of this node's own object bucket: reading the chunks of "+
|
||||
"object %s: %w", strings.Repeat("x", 300), nats.ErrNoResponders)
|
||||
ctx = link.WithFiles(context.Background(), aFilesLink{err: noResponders})
|
||||
report = applyAndKeep(ctx, opts, raw, nil, nil, nil)
|
||||
failed := failedWith("a file of this composition could not be had by its digest: file mesh-vault.kept: " + report.Refused)
|
||||
if !failed.Transient || !strings.HasSuffix(failed.Detail, nats.ErrNoResponders.Error()) || len(failed.Detail) > 310 ||
|
||||
!strings.HasPrefix(failed.Detail, "a file of this composition") {
|
||||
t.Errorf("a fetch that met no responders: transient %v, detail %q (%d bytes); want transient, its head and its cause",
|
||||
failed.Transient, failed.Detail, len(failed.Detail))
|
||||
}
|
||||
|
||||
ctx = link.WithFiles(context.Background(), aFilesLink{objects: map[string][]byte{name: body}})
|
||||
report = applyAndKeep(ctx, opts, raw, nil, nil, nil)
|
||||
if report.Refused != "" {
|
||||
t.Fatalf("1695/AC1: refused: %s", report.Refused)
|
||||
@@ -58,6 +84,77 @@ func TestADeclarationWithAFileByDigestAppliesFromTheObject(t *testing.T) {
|
||||
if err != nil || string(got) != string(body) {
|
||||
t.Errorf("1695/AC1: the file holds %d bytes (%v), want %d", len(got), err, len(body))
|
||||
}
|
||||
|
||||
// Issue 924 (novox, 2026-10-29 01:16): the controller's sweep removed an object a composition the node ran still named,
|
||||
// and the module's apply failed while it ran. Content the node once had is its own keep's, and the bus is not asked.
|
||||
gone := aFilesLink{err: fmt.Errorf("the object %s was deleted from the bucket declaration-files-novox", name)}
|
||||
never := []byte(strings.Repeat("never had here\n", 6000))
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
raw []byte
|
||||
onDisk []byte // nil removes the file
|
||||
refused string // "" applies
|
||||
}{
|
||||
{"the file here drifted, its object deleted", raw, []byte("the module wrote it since"), ""},
|
||||
{"the file here is gone, its object deleted", raw, nil, ""},
|
||||
{"never had here, its object deleted", referenceDeclaration(target, never), nil, "never held it"},
|
||||
} {
|
||||
if row.onDisk == nil {
|
||||
_ = os.Remove(target)
|
||||
} else if err := os.WriteFile(target, row.onDisk, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report = applyAndKeep(link.WithFiles(context.Background(), gone), opts, row.raw, nil, nil, nil)
|
||||
if row.refused == "" {
|
||||
got, _ := os.ReadFile(target)
|
||||
if report.Refused != "" || string(got) != string(body) {
|
||||
t.Errorf("924: %s: refused %q, the file holds %d bytes; want it applied from the node's own keep",
|
||||
row.name, report.Refused, len(got))
|
||||
}
|
||||
} else if !strings.Contains(report.Refused, row.refused) || !strings.Contains(report.Refused, "was deleted") {
|
||||
t.Errorf("924: %s: refused %q; want a refusal saying the bucket and the keep both lack it", row.name, report.Refused)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 924, the live case: what runs came from the kept declaration, whose export names a digest swept long ago,
|
||||
// while the node wrote a newer export since. Composed as it runs, the file names what the node wrote, and applies with
|
||||
// no object; a file someone else changed since is left as named.
|
||||
newer := []byte(strings.Repeat("the export the node wrote since\n", 3000))
|
||||
newerName, _ := declaration.ObjectName(declaration.DigestOf(newer))
|
||||
ctx = link.WithFiles(context.Background(), aFilesLink{objects: map[string][]byte{newerName: newer}})
|
||||
if report = applyAndKeep(ctx, opts, referenceDeclaration(target, newer), nil, nil, nil); report.Refused != "" {
|
||||
t.Fatalf("924: the newer export: %s", report.Refused)
|
||||
}
|
||||
stale := json.RawMessage(`{"id":"m.big","type":"file","path":"` + target + `","digest":"` + declaration.DigestOf(body) +
|
||||
`","size":` + strconv.Itoa(len(body)) + `}`)
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
edit []byte // what someone else wrote there since; nil for nobody
|
||||
digest string
|
||||
}{
|
||||
{"the file is what the node wrote", nil, declaration.DigestOf(newer)},
|
||||
{"someone changed the file since", []byte("a hand edit"), declaration.DigestOf(body)},
|
||||
} {
|
||||
if row.edit != nil {
|
||||
if err := os.WriteFile(target, row.edit, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
runs := runsAsWritten([]engcomp.Composed{{Node: "n", Module: "m", Resources: []json.RawMessage{stale}}}, opts.state)
|
||||
var got struct {
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
_ = json.Unmarshal(runs[0].Resources[0], &got)
|
||||
if got.Digest != row.digest {
|
||||
t.Errorf("924: %s: what runs names %s, want %s", row.name, got.Digest, row.digest)
|
||||
}
|
||||
if row.edit == nil {
|
||||
raw := []byte(`{"declaration":1,"resources":[` + string(runs[0].Resources[0]) + `]}`)
|
||||
if report = applyAndKeep(link.WithFiles(context.Background(), gone), opts, raw, nil, nil, nil); report.Refused != "" {
|
||||
t.Errorf("924: %s: composed as it runs, refused %q; want applied with no object", row.name, report.Refused)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1695/AC8: the hand-run `apply <file>` and `reconcile` have no bus: a reference whose file here verifies is applied
|
||||
|
||||
+97
-10
@@ -3,35 +3,63 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"github.com/novox/mesh-host/internal/filling"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
|
||||
// serveFilling writes filled files on the provisioner client's request for as long as this engine serves (novox/hq
|
||||
// ADR 0349 §4). A socket that cannot be served is said once, and this engine goes on applying.
|
||||
func serveFilling(ctx context.Context, opts options, signer ed25519.PublicKey, say func(string)) {
|
||||
if err := filling.Serve(ctx, filling.Socket, fillingEngine(opts, signer, say), say); err != nil {
|
||||
func serveFilling(ctx context.Context, opts options, signer ed25519.PublicKey, queue *link.Session, node string, say func(string)) {
|
||||
e := fillingEngine(opts, signer, provisionSecretsOn(queue, node, opts.state), say)
|
||||
fillingNow.Store(e)
|
||||
defer fillingNow.CompareAndSwap(e, nil)
|
||||
if err := filling.Serve(ctx, filling.Socket, e, say); err != nil {
|
||||
say("filled files are not written on request on this machine: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// fillingNow is the filling this engine serves, for knockFilling; nil while none is served.
|
||||
var fillingNow atomic.Pointer[filling.Engine]
|
||||
|
||||
// knockFilling tells the provisioner's client to fill again what the filling refused or failed and the declaration
|
||||
// applied now may let through (filling.Engine.Knock), after what runs changed: a module's apply or removal settled
|
||||
// (agent.Env.Settled, "<module> settled"), the haves were read at the start, or a module was taken as running
|
||||
// (agent.Env.RunsChanged, issue 924). What it told, or why it could not, is said.
|
||||
func knockFilling(after string, say func(string)) {
|
||||
e := fillingNow.Load()
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
told, err := e.Knock(filling.Socket)
|
||||
switch {
|
||||
case err != nil:
|
||||
say(fmt.Sprintf("after %s: %v", after, err))
|
||||
case told != "":
|
||||
say(fmt.Sprintf("after %s, the provisioner's client is told to fill again: %s", after, told))
|
||||
}
|
||||
}
|
||||
|
||||
// fillingEngine is the write of a filled file as this engine does it: the declaration it keeps, verified, read under
|
||||
// the apply's lock — the same two locks every apply takes, the process's and the one beside the state — and the file
|
||||
// written through the apply (apply.WriteFilled), its outcome recorded in the node's state as an apply's is.
|
||||
func fillingEngine(opts options, signer ed25519.PublicKey, say func(string)) *filling.Engine {
|
||||
//
|
||||
// A provision's `${secret:}` the client left in the content is filled first (secrets, novox/hq ADR 0570 clause 6), and a
|
||||
// file so filled is written 0600 when the declaration gives no mode.
|
||||
func fillingEngine(opts options, signer ed25519.PublicKey, secrets provisionSecrets, say func(string)) *filling.Engine {
|
||||
return &filling.Engine{
|
||||
Declared: func() (*declaration.Declaration, error) {
|
||||
raw, err := store.LoadDeclared(store.DeclaredPath(opts.state), signer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return declaration.Parse(raw)
|
||||
return declaredNow(opts.state, signer)
|
||||
},
|
||||
UIDOf: filling.UIDOf,
|
||||
Lock: func() (func(), error) {
|
||||
@@ -52,8 +80,13 @@ func fillingEngine(opts options, signer ed25519.PublicKey, say func(string)) *fi
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
report, updated, writeErr := apply.WriteFilled(ctx, built, d, known, path, content, apply.ExecRunner,
|
||||
announceOr(say), apply.KeepIn(filepath.Dir(opts.state)), apply.WindowsIn(filepath.Dir(opts.state)))
|
||||
content, secret, err := secrets.fill(ctx, filledModule(d, path), content)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s was not written: %w", path, err)
|
||||
}
|
||||
report, updated, writeErr := apply.WriteFilledWith(ctx, built, d, known, path, content, apply.ExecRunner,
|
||||
announceOr(say), apply.KeepIn(filepath.Dir(opts.state)), apply.WindowsIn(filepath.Dir(opts.state)),
|
||||
apply.Filled{Secret: secret})
|
||||
// Saved whichever way it went, as an apply's: what was written is on the machine either way.
|
||||
if err := store.Save(opts.state, updated); err != nil {
|
||||
return "", "", errors.Join(writeErr, errors.New("written, and the node's state could not be saved: "+
|
||||
@@ -72,3 +105,57 @@ func fillingEngine(opts options, signer ed25519.PublicKey, say func(string)) *fi
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// filledModule is the module whose file the declaration declares at path, or "" when none or several do.
|
||||
func filledModule(d *declaration.Declaration, path string) string {
|
||||
module := ""
|
||||
for _, r := range d.Resources {
|
||||
if f, ok := r.(*declaration.File); ok && f.Path == path {
|
||||
if module != "" {
|
||||
return ""
|
||||
}
|
||||
module, _ = d.ModuleOfID(f.ID)
|
||||
}
|
||||
}
|
||||
return module
|
||||
}
|
||||
|
||||
// provisionSecretsOn reads provisions and asks the vault over the link, and opens with this machine's sealing key.
|
||||
func provisionSecretsOn(queue *link.Session, node, statePath string) provisionSecrets {
|
||||
p := provisionSecrets{node: node, open: sealOpener(statePath)}
|
||||
if queue == nil {
|
||||
return p
|
||||
}
|
||||
// What the bus holds under prefix, read by a watch to its caught-up mark: no poll, no list of every key.
|
||||
p.list = func(ctx context.Context, prefix string) (map[string][]byte, error) {
|
||||
wctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
updates, err := queue.KVWatch(wctx, buckets.Provisions, prefix+">")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]byte{}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case u, ok := <-updates:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the watch of %s ended before it caught up", buckets.Provisions)
|
||||
}
|
||||
switch {
|
||||
case u.Caught:
|
||||
return out, nil
|
||||
case u.Deleted:
|
||||
delete(out, u.Key)
|
||||
default:
|
||||
out[u.Key] = u.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p.own = func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
return queue.AskVault(ctx, "own", args)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
|
||||
|
||||
"github.com/novox/mesh-host/internal/filling"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
@@ -45,7 +48,34 @@ func TestAWriteAndAnApplyOfTheSameFileSerialise(t *testing.T) {
|
||||
store.Declared{Declaration: raw, Signature: ed25519.Sign(private, raw)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := fillingEngine(opts, public, func(string) {})
|
||||
// The provision m reads, as mesh-provisioner keeps it, and the vault's value of it, sealed to this machine (faked:
|
||||
// the opener reads the box's "v").
|
||||
provision := map[string]string{}
|
||||
vaultName := "provision/n2/db"
|
||||
secrets := provisionSecrets{node: "n1",
|
||||
list: func(_ context.Context, prefix string) (map[string][]byte, error) {
|
||||
out := map[string][]byte{}
|
||||
for k, v := range provision {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
out[k] = []byte(v)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
own: func(_ context.Context, args map[string]any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(args)
|
||||
var a struct{ Credentials []map[string]any }
|
||||
_ = json.Unmarshal(raw, &a)
|
||||
c := a.Credentials[0]
|
||||
c["name"], c["generation"], c["sealed"] = vaultName, 2, map[string]string{"v": "s3cret"}
|
||||
return json.Marshal(map[string]any{"node": "n1", "credentials": []any{c}})
|
||||
},
|
||||
open: func(sealed string) ([]byte, error) {
|
||||
var b struct{ V string }
|
||||
err := json.Unmarshal([]byte(sealed), &b)
|
||||
return []byte(b.V), err
|
||||
}}
|
||||
e := fillingEngine(opts, public, secrets, func(string) {})
|
||||
uid := uint32(os.Getuid())
|
||||
if err := e.Accepts(uid); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -102,4 +132,75 @@ func TestAWriteAndAnApplyOfTheSameFileSerialise(t *testing.T) {
|
||||
if a := <-done; a.Action != "updated" {
|
||||
t.Fatalf("the second write: %+v", a)
|
||||
}
|
||||
|
||||
// ADR 0570 clause 6: the client leaves a provision's `${secret:}` to the node-engine, which fills it at the write
|
||||
// from the vault's value of a provided provision, and writes the file 0600; a provision not provided refuses it.
|
||||
keyed := func(state string, ref bool, id, local string) string {
|
||||
p := provisions.Provision{Provision: "db", Local: local, Consumer: provisions.End{Module: "m", Node: "n1"},
|
||||
Provider: provisions.End{Module: "postgres", Node: "n2"}, State: state, At: time.Now()}
|
||||
if state != provisions.Provided {
|
||||
p.Why = "the provider has not answered"
|
||||
}
|
||||
if ref {
|
||||
p.Reference = &provisions.Reference{ID: id, Generation: 2}
|
||||
}
|
||||
raw, err := provisions.Encode(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, state string
|
||||
ref bool
|
||||
content string
|
||||
want string // the file after; "" keeps what was there
|
||||
mode os.FileMode
|
||||
vault string // the vault's credential name, when not the one the provision names
|
||||
refID string // the reference's id, when not the provision's shared value
|
||||
local string // the provision's local name (ADR 0094): the placeholder names it
|
||||
}{
|
||||
{name: "a provision not provided is refused", state: provisions.Asked, content: "password=${secret:db}\n"},
|
||||
{name: "provided with no reference is refused", state: provisions.Provided, content: "password=${secret:db}\n"},
|
||||
{name: "a reference to no credential of the provision is refused", state: provisions.Provided, ref: true,
|
||||
refID: "provision/n9/db", content: "password=${secret:db}\n"},
|
||||
{name: "a name no provision goes by is refused", state: provisions.Provided, ref: true, content: "password=${secret:other}\n"},
|
||||
{name: "the vault's value is another than the provision names", state: provisions.Provided, ref: true,
|
||||
vault: "pair/n2/db/n1/m", content: "password=${secret:db}\n"},
|
||||
{name: "provided: filled at the write, 0600", state: provisions.Provided, ref: true,
|
||||
content: "password=${secret:db}\n", want: "password=s3cret\n", mode: 0o600},
|
||||
{name: "a local name is the provision's by its key", state: provisions.Provided, ref: true, local: "admin",
|
||||
content: "admin=${secret:admin}\n", want: "admin=s3cret\n", mode: 0o600},
|
||||
{name: "no placeholder is written as it came, as the declaration says", content: "plain\n", want: "plain\n", mode: 0o644},
|
||||
} {
|
||||
before, _ := os.ReadFile(path)
|
||||
if tc.state != "" {
|
||||
id := "provision/n2/db"
|
||||
if tc.refID != "" {
|
||||
id = tc.refID
|
||||
}
|
||||
clear(provision)
|
||||
key := "n1.m.db"
|
||||
if tc.local != "" {
|
||||
key += "." + tc.local
|
||||
}
|
||||
provision[key] = keyed(tc.state, tc.ref, id, tc.local)
|
||||
}
|
||||
vaultName = "provision/n2/db"
|
||||
if tc.vault != "" {
|
||||
vaultName = tc.vault
|
||||
}
|
||||
a := e.Write(context.Background(), uid, filling.Request{Path: path, Content: []byte(tc.content)})
|
||||
got, _ := os.ReadFile(path)
|
||||
if tc.want == "" {
|
||||
if a.Failed == "" && a.Refused == "" || string(got) != string(before) {
|
||||
t.Errorf("%s: answered %+v, the file holds %q", tc.name, a, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
info, _ := os.Stat(path)
|
||||
if a.Failed != "" || a.Refused != "" || string(got) != tc.want || info.Mode().Perm() != tc.mode {
|
||||
t.Errorf("%s: answered %+v, the file holds %q, mode %v", tc.name, a, got, info.Mode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-19
@@ -64,16 +64,16 @@ func foundDirectories(known store.State) []store.Applied {
|
||||
return out
|
||||
}
|
||||
|
||||
// handOverLine is the operator's line that hands a directory to the mesh, with this node's name when it is
|
||||
// known (novox/hq ADR 0272): typed on the control-node, from the operator's own account. The directory's
|
||||
// path is the resource's target beside the reason, never in it: the controller says a reason on the
|
||||
// operator's channel, which withholds a path (ADR 0234 §6), and keeps the target as evidence.
|
||||
// handOverLine is the way out of a directory used as found, with this node's name when it is known: the node seat's
|
||||
// hand-over with the directory's path, which asks the operator on their channel (novox/hq ADR 0563 §5). The controller
|
||||
// that answered `nox node hand-over` ended by its unassignment (issue 1126), so that line has no answerer. The path is
|
||||
// the resource's target beside the reason, never in it: the operator's channel withholds a path (ADR 0234 §6).
|
||||
func handOverLine() string {
|
||||
node, _ := foundNode.Load().(string)
|
||||
if node == "" {
|
||||
node = "<node>"
|
||||
}
|
||||
return fmt.Sprintf("`nox node hand-over %s <directory>` on the control-node", node)
|
||||
return fmt.Sprintf("`%s/node-engine.hand-over` with the directory's path, then your yes on your channel", node)
|
||||
}
|
||||
|
||||
// withFoundDirectories adds to a statement every directory used as found, as its module's resource of kind
|
||||
@@ -129,16 +129,30 @@ func foundNow() []store.Applied {
|
||||
// never follows a path it did not state: a hand-over is root giving a directory to an account at the next
|
||||
// apply, and a path that resolves elsewhere would give that elsewhere.
|
||||
func handOverFound(statePath, path, by string) (string, error) {
|
||||
found, _, err := judgeFound(statePath, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := store.AddHandedOver(statePath, store.HandOver{ID: found.ID, Path: path, At: time.Now().UTC(), By: by}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s (%s) is handed to the mesh by %s: the next apply gives it %s, and it is the mesh's from "+
|
||||
"then on — removed when empty and no longer declared", path, found.ID, by, orUnsaid(found.Declared)), nil
|
||||
}
|
||||
|
||||
// judgeFound is the judgement of a hand-over's path, recording nothing: the directory this machine uses as found at
|
||||
// that path, as the engine states it, and what is on disk there.
|
||||
func judgeFound(statePath, path string) (store.Applied, os.FileInfo, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("hand-over takes the directory's absolute path, and %q is not one; nothing was handed over", path)
|
||||
return store.Applied{}, nil, fmt.Errorf("hand-over takes the directory's absolute path, and %q is not one; nothing was handed over", path)
|
||||
}
|
||||
if filepath.Clean(path) != path {
|
||||
return "", fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or trailing "+
|
||||
return store.Applied{}, nil, fmt.Errorf("%q is not the directory's path as the engine states it (no `..`, no doubled or trailing "+
|
||||
"separator); nothing was handed over", path)
|
||||
}
|
||||
known, err := store.Load(statePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return store.Applied{}, nil, err
|
||||
}
|
||||
var found *store.Applied
|
||||
for _, r := range foundDirectories(known) {
|
||||
@@ -149,31 +163,27 @@ func handOverFound(statePath, path, by string) (string, error) {
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return "", errors.New(path + " is not a directory this machine uses as found (the module's condition names " +
|
||||
return store.Applied{}, nil, errors.New(path + " is not a directory this machine uses as found (the module's condition names " +
|
||||
"the ones that are); nothing was handed over")
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
switch {
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("%s cannot be read on this machine: %v; nothing was handed over", path, err)
|
||||
return store.Applied{}, nil, fmt.Errorf("%s cannot be read on this machine: %v; nothing was handed over", path, err)
|
||||
case info.Mode()&os.ModeSymlink != 0:
|
||||
return "", fmt.Errorf("%s is a link, and the mesh hands over no link (novox/hq ADR 0012); nothing was handed over", path)
|
||||
return store.Applied{}, nil, fmt.Errorf("%s is a link, and the mesh hands over no link (novox/hq ADR 0012); nothing was handed over", path)
|
||||
case !info.IsDir():
|
||||
return "", fmt.Errorf("%s is not a directory on this machine; nothing was handed over", path)
|
||||
return store.Applied{}, nil, fmt.Errorf("%s is not a directory on this machine; nothing was handed over", path)
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s cannot be resolved on this machine: %v; nothing was handed over", path, err)
|
||||
return store.Applied{}, nil, fmt.Errorf("%s cannot be resolved on this machine: %v; nothing was handed over", path, err)
|
||||
}
|
||||
if resolved != path {
|
||||
return "", fmt.Errorf("%s is reached through a link (it is %s), and the mesh hands over nothing through one "+
|
||||
return store.Applied{}, nil, fmt.Errorf("%s is reached through a link (it is %s), and the mesh hands over nothing through one "+
|
||||
"(novox/hq issue 339); nothing was handed over", path, resolved)
|
||||
}
|
||||
if err := store.AddHandedOver(statePath, store.HandOver{ID: found.ID, Path: path, At: time.Now().UTC(), By: by}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s (%s) is handed to the mesh by %s: the next apply gives it %s, and it is the mesh's from "+
|
||||
"then on — removed when empty and no longer declared", path, found.ID, by, orUnsaid(found.Declared)), nil
|
||||
return *found, info, nil
|
||||
}
|
||||
|
||||
// handOverFrom is the engine's hand-over for the controller's ask: the state beside statePath, the path as asked.
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
// Every directory used as found is its module's resource of kind directory, unhealthy, naming its owner, on
|
||||
// every statement (novox/hq issue 339): the controller raises that as the module's condition. The way out it
|
||||
// names is the operator's line, with this node's name (novox/hq ADR 0272, issue 356).
|
||||
// names is the node seat's hand-over, with this node's name (novox/hq ADR 0563 §5).
|
||||
func TestAFoundDirectoryIsStatedUnhealthy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
known := store.State{Resources: []store.Applied{
|
||||
@@ -35,14 +35,15 @@ func TestAFoundDirectoryIsStatedUnhealthy(t *testing.T) {
|
||||
t.Fatalf("stated as: %+v", r)
|
||||
}
|
||||
// The path is the target, kept as evidence, and never in the reason: the operator's channel withholds a path
|
||||
// (novox/hq ADR 0234 §6), and the controller says the reason there.
|
||||
if !strings.Contains(r.Reason, "`nox node hand-over laptop <directory>` on the control-node") ||
|
||||
// (novox/hq ADR 0234 §6). The way out is the node seat's hand-over (ADR 0563 §5), never the controller's terminal line,
|
||||
// which nothing answers since the controller ended (issue 1126).
|
||||
if !strings.Contains(r.Reason, "`laptop/node-engine.hand-over`") || strings.Contains(r.Reason, "nox node hand-over") ||
|
||||
strings.Contains(r.Reason, "mesh-host") || strings.Contains(r.Reason, dir) {
|
||||
t.Fatalf("the way out is not the operator's line, or carries the path: %q", r.Reason)
|
||||
t.Fatalf("the way out is not the node seat's hand-over, or carries the path: %q", r.Reason)
|
||||
}
|
||||
foundNode.Store("")
|
||||
if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason,
|
||||
"`nox node hand-over <node> <directory>`") {
|
||||
"`<node>/node-engine.hand-over`") {
|
||||
t.Fatalf("before enrolment the line names a placeholder: %q", r.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
|
||||
// **The generation applied is kept, and a put-back never lowers it**: after a put-back of generation 5
|
||||
// over 7, a send from generation 6 is still older than what this machine applied.
|
||||
func TestThePutBackDoesNotLowerTheGenerationApplied(t *testing.T) {
|
||||
built, err := system.For("arch")
|
||||
if err != nil || built.Confirm(context.Background(), apply.ExecRunner) != nil {
|
||||
t.Skip("this machine is not one these tests can apply on")
|
||||
}
|
||||
was := builtFor
|
||||
builtFor = "arch"
|
||||
t.Cleanup(func() { builtFor = was })
|
||||
f := machineThatApplied(t, 7)
|
||||
|
||||
newer := f.declared(t, map[string]any{"sequence": 9, "generation": 8}, "docker.conf", f.stays)
|
||||
if r := applyAndKeep(context.Background(), f.opts, newer.Declaration, &newer, nil, nil); r.Refused != "" {
|
||||
t.Fatalf("a newer generation was refused: %s", r.Refused)
|
||||
}
|
||||
back := f.declared(t, map[string]any{"sequence": 10, "generation": 5, "put_back": true}, "docker.conf", f.stays)
|
||||
if r := applyAndKeep(context.Background(), f.opts, back.Declaration, &back, nil, nil); r.Refused != "" {
|
||||
t.Fatalf("a put-back was refused: %s", r.Refused)
|
||||
}
|
||||
state, err := store.Load(f.opts.state)
|
||||
if err != nil || state.Generation != 8 {
|
||||
t.Fatalf("the generation applied is %d after a put-back of 5 over 8 (%v)", state.Generation, err)
|
||||
}
|
||||
stale := f.declared(t, map[string]any{"sequence": 11, "generation": 7}, "docker.conf", f.stays)
|
||||
if r := applyAndKeep(context.Background(), f.opts, stale.Declaration, &stale, nil, nil); r.OlderGeneration == nil {
|
||||
t.Fatalf("a send from generation 7 after 8 was applied because a put-back came between: %+v", r)
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// The assignment generation a declaration was composed from (novox/hq issue 234).
|
||||
//
|
||||
// On 2026-10-04 a declaration newer in sequence than every other, and composed from a view of the
|
||||
// assignments older than the one this machine had applied, named four fewer modules — and the host,
|
||||
// ordering by sequence alone, undeclared all four. The sequence orders arrival; it cannot tell a later
|
||||
// send carrying earlier content. The generation can: the node-engine refuses a declaration composed from
|
||||
// an older generation than the one it applied, before anything is read from the machine.
|
||||
//
|
||||
// These read what the report says through its JSON, as the controller does, so they run against a
|
||||
// node-engine that has never heard of a generation and fail there for that reason.
|
||||
|
||||
// generationFixture is a machine that applied generation 7: a file the mesh declared, kept in its state,
|
||||
// and the declaration it kept — signed — that named it.
|
||||
type generationFixture struct {
|
||||
opts options
|
||||
private ed25519.PrivateKey
|
||||
kept store.Declared
|
||||
// stays is the file the older generation's declaration does not name.
|
||||
stays string
|
||||
}
|
||||
|
||||
func machineThatApplied(t *testing.T, generation int64) generationFixture {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
opts := options{state: filepath.Join(dir, "state.json")}
|
||||
_, private, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stays := filepath.Join(dir, "docker.conf")
|
||||
if err := os.WriteFile(stays, []byte("kept\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The state as a node-engine that applied generation 7 keeps it: the file it owns, and the highest
|
||||
// generation it applied. Written as JSON, so the fixture is the same bytes for any build under test.
|
||||
state := map[string]any{"resources": []any{map[string]any{"id": "docker.conf", "type": "file",
|
||||
"origin": store.OriginDeclared, "target": stays}}, "generation": generation}
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(opts.state, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := generationFixture{opts: opts, private: private, stays: stays}
|
||||
f.kept = f.declared(t, map[string]any{"sequence": 3, "generation": generation}, "docker.conf", stays)
|
||||
if err := store.SaveDeclared(store.DeclaredPath(opts.state), f.kept); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// declared is a signed declaration with the envelope's keys given and one file.
|
||||
func (f generationFixture) declared(t *testing.T, envelope map[string]any, id, path string) store.Declared {
|
||||
t.Helper()
|
||||
body := map[string]any{"declaration": 1,
|
||||
"resources": []any{map[string]any{"id": id, "type": "file", "path": path, "content": "x\n"}}}
|
||||
for k, v := range envelope {
|
||||
body[k] = v
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store.Declared{Declaration: raw, Signature: ed25519.Sign(f.private, raw)}
|
||||
}
|
||||
|
||||
// reportJSON is a report as the controller reads it.
|
||||
func reportJSON(t *testing.T, r link.Report) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// **A newer sequence from an older generation is refused, and nothing is undeclared** (issue 234).
|
||||
func TestANewerSequenceFromAnOlderGenerationIsRefusedAndNothingIsUndeclared(t *testing.T) {
|
||||
f := machineThatApplied(t, 7)
|
||||
other := filepath.Join(filepath.Dir(f.opts.state), "sudo.conf")
|
||||
stale := f.declared(t, map[string]any{"sequence": 9, "generation": 6}, "sudo.conf", other)
|
||||
|
||||
report := applyAndKeep(context.Background(), f.opts, stale.Declaration, &stale, nil, nil)
|
||||
said := reportJSON(t, report)
|
||||
older, _ := said["older_generation"].(map[string]any)
|
||||
if older == nil || older["generation"] != float64(6) || older["applied"] != float64(7) {
|
||||
t.Fatalf("the refusal does not name the generation refused and the one applied: %v", said)
|
||||
}
|
||||
if report.Refused == "" || !strings.Contains(report.Refused, "generation 6") ||
|
||||
!strings.Contains(report.Refused, "generation 7") {
|
||||
t.Errorf("the refusal is not said in words naming both generations: %q", report.Refused)
|
||||
}
|
||||
if report.Declared != digestOf(stale.Declaration) || report.Sequence != 9 {
|
||||
t.Errorf("the refusal does not name the declaration refused, so the mesh cannot name who sent it: %+v", report)
|
||||
}
|
||||
if _, err := os.Stat(f.stays); err != nil {
|
||||
t.Fatalf("a refused declaration undeclared what the machine applied: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(other); err == nil {
|
||||
t.Error("the refused declaration touched the machine")
|
||||
}
|
||||
state, err := store.Load(f.opts.state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, owned := state.Find("docker.conf"); !owned {
|
||||
t.Error("the refused declaration took a resource out of what this machine owns")
|
||||
}
|
||||
kept, err := store.ReadDeclared(store.DeclaredPath(f.opts.state))
|
||||
if err != nil || string(kept.Declaration) != string(f.kept.Declaration) {
|
||||
t.Error("the refused declaration replaced what this node kept, so the next reconcile would apply it")
|
||||
}
|
||||
}
|
||||
|
||||
// refusedForItsGeneration says a report refused its declaration for the generation it came from.
|
||||
func refusedForItsGeneration(t *testing.T, r link.Report) bool {
|
||||
t.Helper()
|
||||
return reportJSON(t, r)["older_generation"] != nil || strings.Contains(r.Refused, "generation")
|
||||
}
|
||||
|
||||
// refusedAsUnread says a report refused its declaration as one this node-engine cannot read.
|
||||
func refusedAsUnread(r link.Report) bool { return strings.Contains(r.Refused, "not a declaration") }
|
||||
|
||||
// **A gate's put-back is applied, whatever generation it carries**: it carries the generation of what it
|
||||
// puts back, and says it is a put-back (issue 234).
|
||||
func TestAPutBackFromAnOlderGenerationIsNotRefused(t *testing.T) {
|
||||
f := machineThatApplied(t, 7)
|
||||
back := f.declared(t, map[string]any{"sequence": 9, "generation": 5, "put_back": true}, "docker.conf", f.stays)
|
||||
report := applyAndKeep(context.Background(), f.opts, back.Declaration, &back, nil, nil)
|
||||
if refusedForItsGeneration(t, report) || refusedAsUnread(report) {
|
||||
t.Fatalf("a put-back was refused: %s", report.Refused)
|
||||
}
|
||||
}
|
||||
|
||||
// **A declaration that carries no generation is applied as it always was**: an older controller sends
|
||||
// none, and a node-engine that refused it would strand every machine the moment the controller is older.
|
||||
func TestADeclarationWithoutAGenerationIsNotRefusedForOne(t *testing.T) {
|
||||
f := machineThatApplied(t, 7)
|
||||
older := f.declared(t, map[string]any{"sequence": 9}, "docker.conf", f.stays)
|
||||
report := applyAndKeep(context.Background(), f.opts, older.Declaration, &older, nil, nil)
|
||||
if refusedForItsGeneration(t, report) || refusedAsUnread(report) {
|
||||
t.Fatalf("a declaration without a generation was refused: %s", report.Refused)
|
||||
}
|
||||
// And the same generation again is no older.
|
||||
same := f.declared(t, map[string]any{"sequence": 10, "generation": 7}, "docker.conf", f.stays)
|
||||
report = applyAndKeep(context.Background(), f.opts, same.Declaration, &same, nil, nil)
|
||||
if refusedForItsGeneration(t, report) || refusedAsUnread(report) {
|
||||
t.Fatalf("a declaration of the generation applied was refused: %s", report.Refused)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// owedBus is a link that takes reports and statements and keeps a have bucket in memory.
|
||||
type owedBus struct {
|
||||
mu sync.Mutex
|
||||
kv map[string][]byte
|
||||
rv map[string]uint64
|
||||
}
|
||||
|
||||
func (b *owedBus) Health(context.Context, string, []byte) error { return nil }
|
||||
func (b *owedBus) HaveBind(context.Context) (link.HaveBucket, error) { return b, nil }
|
||||
|
||||
func (b *owedBus) Get(_ context.Context, key string) ([]byte, uint64, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
v, ok := b.kv[key]
|
||||
if !ok {
|
||||
return nil, 0, link.ErrHaveAbsent
|
||||
}
|
||||
return v, b.rv[key], nil
|
||||
}
|
||||
|
||||
func (b *owedBus) Set(_ context.Context, key string, value []byte, revision uint64) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.rv[key] != revision {
|
||||
return errors.New("wrong last sequence")
|
||||
}
|
||||
b.kv[key], b.rv[key] = value, revision+1
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *owedBus) have(key string) (nodeengine.HaveByNodeModuleValue, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
h, err := nodeengineclient.DecodeHave(b.kv[key])
|
||||
return h, err == nil
|
||||
}
|
||||
|
||||
// A module with nothing long-running (xorg) is ok within one look of its apply, not at the five-minute statement: the
|
||||
// facts look inside the apply runs while it is applying and judges nothing, and the running write then owes a look,
|
||||
// which the health loop makes at once (review of fix 15, B1).
|
||||
func TestAModuleWithNothingLongRunningIsOkWithinALookOfItsApply(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
bus := &owedBus{kv: map[string][]byte{}, rv: map[string]uint64{}}
|
||||
queue := &link.Session{Membership: link.Membership{Node: "n1"}, Timeout: time.Second}
|
||||
queue.Attach(ctx, bus)
|
||||
j, err := liveness.Open(filepath.Join(t.TempDir(), "liveness.json"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fact := link.ModuleFact{Module: "xorg", FP: "src2:" + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("b", 64)}
|
||||
applying := fact
|
||||
applying.Phase = string(nodeengineclient.PhaseApplying)
|
||||
queue.NoteModule(ctx, applying)
|
||||
go judgeWhatRuns(ctx, j, queue, func(string) {}, nil, nil)
|
||||
// The facts look inside the apply, while the module is still applying.
|
||||
queue.SayFacts(ctx, link.Report{Health: &link.Health{Contract: link.LivenessContract, At: time.Now().UTC()}})
|
||||
if h, _ := bus.have("n1.xorg"); h.Health != nodeengineclient.HealthUnknown {
|
||||
t.Fatalf("judged while applying: %+v", h)
|
||||
}
|
||||
queue.NoteModule(ctx, fact)
|
||||
deadline := time.Now().Add(2 * time.Second) // well within one look (liveness.LookEvery, 15 s)
|
||||
for {
|
||||
h, _ := bus.have("n1.xorg")
|
||||
if h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("not ok within a look of its apply: %+v", h)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
)
|
||||
|
||||
// heldBy is the containers an open maintenance window holds still, by runtime name (ADR 0189), at now.
|
||||
func heldBy(windows *apply.Windows) func(time.Time) map[string]bool {
|
||||
return func(now time.Time) map[string]bool {
|
||||
held := map[string]bool{}
|
||||
for _, w := range windows.Open(now) {
|
||||
for _, name := range w.Holds {
|
||||
held[name] = true
|
||||
}
|
||||
}
|
||||
return held
|
||||
}
|
||||
}
|
||||
|
||||
// inactiveIn is what the agent asks at its start of a module it believes runs (ADR 0568 §1): whether the machine shows a
|
||||
// long-running resource the composition declares running not active, read from the service manager and the container
|
||||
// runtime and from no record. A resource held by an open maintenance window, or waiting for a library, is not counted;
|
||||
// one that cannot be read is not counted either (unknown is not inactive), and neither is one the manager is restarting.
|
||||
func inactiveIn(rt liveness.Runtime, held func(time.Time) map[string]bool) func(context.Context, engcomp.Composed) bool {
|
||||
return func(ctx context.Context, c engcomp.Composed) bool {
|
||||
d, err := declarationOf(c)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var holding map[string]bool
|
||||
if held != nil {
|
||||
holding = held(time.Now())
|
||||
}
|
||||
return anyInactive(ctx, rt, liveness.LongRunning(d, withWaiting(nil, apply.WaitingOnLibrary(d))), holding)
|
||||
}
|
||||
}
|
||||
|
||||
// anyInactive reads each group of resources once (the containers, each service manager's units) and says whether any is
|
||||
// not active.
|
||||
func anyInactive(ctx context.Context, rt liveness.Runtime, resources []liveness.Resource, held map[string]bool) bool {
|
||||
type group struct {
|
||||
kind, scope, user string
|
||||
targets []string
|
||||
}
|
||||
var groups []*group
|
||||
at := map[string]*group{}
|
||||
for _, r := range resources {
|
||||
if r.Kind == liveness.KindContainer && held[r.Target] {
|
||||
continue
|
||||
}
|
||||
g := &group{kind: r.Kind, scope: r.Scope, user: r.User}
|
||||
if r.Kind == liveness.KindContainer {
|
||||
g.scope, g.user = "", ""
|
||||
}
|
||||
key := g.kind + "/" + g.scope + "/" + g.user
|
||||
if r.Kind != liveness.KindContainer {
|
||||
key = "units/" + g.scope + "/" + g.user
|
||||
}
|
||||
if at[key] == nil {
|
||||
at[key] = g
|
||||
groups = append(groups, g)
|
||||
}
|
||||
at[key].targets = append(at[key].targets, r.Target)
|
||||
}
|
||||
for _, g := range groups {
|
||||
var got map[string]liveness.Observed
|
||||
var err error
|
||||
if g.kind == liveness.KindContainer {
|
||||
got, err = rt.Containers(ctx, g.targets)
|
||||
} else {
|
||||
got, err = rt.Units(ctx, g.scope, g.user, g.targets)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range g.targets {
|
||||
if o := got[t]; !o.Found || !(o.Running || o.Restarting) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
)
|
||||
|
||||
// 1957/AC2 and AC4 (unit; ADR 0568 §2, §4): the judge's look acts on a resource that goes down, once per edge. A repeated
|
||||
// statement acts nothing, the edge clears when the resource is healthy again, and a resource held, restarting, starting or
|
||||
// missing (the other path's) is no edge.
|
||||
func testOnlyAResourceNewlyDownIsAnEdge(t *testing.T) {
|
||||
state := func(id, s, reason string, missing bool) liveness.State {
|
||||
return liveness.State{Resource: liveness.Resource{Module: "dnsmasq", ID: id}, State: s, Reason: reason, Missing: missing}
|
||||
}
|
||||
down := state("dnsmasq.unit", liveness.Unhealthy, liveness.ReasonDown, false)
|
||||
steps := []struct {
|
||||
name string
|
||||
st []liveness.State
|
||||
want []string
|
||||
}{
|
||||
{"down: an edge", []liveness.State{down}, []string{"dnsmasq"}},
|
||||
{"said again while down: none", []liveness.State{down}, nil},
|
||||
{"healthy again: clears", []liveness.State{state("dnsmasq.unit", liveness.Healthy, "", false)}, nil},
|
||||
{"down again: an edge", []liveness.State{down}, []string{"dnsmasq"}},
|
||||
{"held by a window: none", []liveness.State{state("dnsmasq.unit", liveness.Held, "", false)}, nil},
|
||||
{"restarting: none", []liveness.State{state("dnsmasq.unit", liveness.Unhealthy, liveness.ReasonRestarting, false)}, nil},
|
||||
{"starting: none", []liveness.State{state("dnsmasq.unit", liveness.Starting, "", false)}, nil},
|
||||
{"missing: the other path's", []liveness.State{state("dnsmasq.unit", liveness.Unhealthy, liveness.ReasonDown, true)}, nil},
|
||||
}
|
||||
var last map[string]bool
|
||||
for _, s := range steps {
|
||||
var got []string
|
||||
got, last = newlyInactive(last, liveness.Statement{Resources: s.st})
|
||||
if len(got) != len(s.want) || (len(got) == 1 && got[0] != s.want[0]) {
|
||||
t.Fatalf("%s: acted on %v, want %v", s.name, got, s.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRuntime struct {
|
||||
units map[string]liveness.Observed
|
||||
containers map[string]liveness.Observed
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeRuntime) Containers(_ context.Context, _ []string) (map[string]liveness.Observed, error) {
|
||||
return f.containers, f.err
|
||||
}
|
||||
func (f fakeRuntime) Units(_ context.Context, _, _ string, _ []string) (map[string]liveness.Observed, error) {
|
||||
return f.units, f.err
|
||||
}
|
||||
|
||||
// 1957/AC1 (unit; ADR 0568 §1): the machine's facts say a declared-running resource is not active when its unit is stopped
|
||||
// or unknown to the manager; everything up, a restart in progress, a read that failed and a container held by a
|
||||
// maintenance window are not.
|
||||
func testTheMachineShowsAResourceNotActive(t *testing.T) {
|
||||
svc := []byte(`{"id":"dnsmasq.unit","type":"service","unit":"dnsmasq.service","state":"running","module":"dnsmasq"}`)
|
||||
ctr := []byte(`{"id":"dnsmasq.box","type":"container","name":"box","image":"x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","module":"dnsmasq"}`)
|
||||
up := liveness.Observed{Found: true, Running: true}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
rt fakeRuntime
|
||||
held map[string]bool
|
||||
want bool
|
||||
}{
|
||||
{"all up", fakeRuntime{units: map[string]liveness.Observed{"dnsmasq.service": up}, containers: map[string]liveness.Observed{"box": up}}, nil, false},
|
||||
{"unit inactive", fakeRuntime{units: map[string]liveness.Observed{"dnsmasq.service": {Found: true}}, containers: map[string]liveness.Observed{"box": up}}, nil, true},
|
||||
{"unit unknown to the manager", fakeRuntime{containers: map[string]liveness.Observed{"box": up}}, nil, true},
|
||||
{"unit restarting", fakeRuntime{units: map[string]liveness.Observed{"dnsmasq.service": {Found: true, Restarting: true}}, containers: map[string]liveness.Observed{"box": up}}, nil, false},
|
||||
{"container not running", fakeRuntime{units: map[string]liveness.Observed{"dnsmasq.service": up}, containers: map[string]liveness.Observed{"box": {Found: true}}}, nil, true},
|
||||
{"container held by a window", fakeRuntime{units: map[string]liveness.Observed{"dnsmasq.service": up}, containers: map[string]liveness.Observed{"box": {Found: true}}}, map[string]bool{"box": true}, false},
|
||||
{"unread", fakeRuntime{err: errors.New("the service manager could not be read")}, nil, false},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
d, err := declarationOf(engcomp.Composed{Node: "n1", Module: "dnsmasq", Resources: []json.RawMessage{svc, ctr}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyInactive(context.Background(), c.rt, liveness.LongRunning(d, nil), c.held); got != c.want {
|
||||
t.Fatalf("inactive = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
@@ -13,16 +12,12 @@ import (
|
||||
// judgeDirectories judges every directory of the declaration this node keeps, read-only (novox/hq issue 358): which
|
||||
// the walk that follows no link another account could make would refuse. Nothing is made, chmodded or chowned.
|
||||
func judgeDirectories(statePath string) (string, error) {
|
||||
kept, err := store.ReadDeclared(store.DeclaredPath(statePath))
|
||||
d, err := declaredNow(statePath, nil)
|
||||
if errors.Is(err, store.ErrNothingDeclared) {
|
||||
return "", errors.New("this node keeps no declaration, so it is declared no directory")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d, err := declaration.Parse(kept.Declaration)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the declaration this node keeps does not read: %w", err)
|
||||
return "", fmt.Errorf("what runs on this node does not read: %w", err)
|
||||
}
|
||||
return apply.JudgeDirectories(d), nil
|
||||
}
|
||||
|
||||
@@ -251,16 +251,12 @@ func startKeeping(ctx context.Context, say func(string)) {
|
||||
// declaration this node keeps, and run beside the applies.
|
||||
func accessActFrom(statePath string, timeout time.Duration) link.AccessActFunc {
|
||||
return func(act link.AccessAct) (string, error) {
|
||||
kept, err := store.ReadDeclared(store.DeclaredPath(statePath))
|
||||
d, err := declaredNow(statePath, nil)
|
||||
if errors.Is(err, store.ErrNothingDeclared) {
|
||||
return "", errors.New("this node keeps no declaration, so it is declared no library")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
d, err := declaration.Parse(kept.Declaration)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the declaration this node keeps does not read: %w", err)
|
||||
return "", fmt.Errorf("what runs on this node does not read: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
@@ -53,4 +55,42 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) {
|
||||
if strings.Contains(media.Reason, dir) || len(books.AccessWaits) != 1 || books.AccessWaits[0].Reason != link.WaitDisk {
|
||||
t.Fatalf("a missing root does not wait for its disk, or a reason names a path: %+v %+v", books, media)
|
||||
}
|
||||
// 774 (ADR 0563 §4): the library's home engine asks its take of the operator itself, once: no second ask while one is
|
||||
// open, nor after it ended; and a library waiting for its disk is not asked.
|
||||
t.Run("the home engine asks the take once", func(t *testing.T) {
|
||||
var asked []oc.Ask
|
||||
state := &link.MemState{}
|
||||
h := &link.HandActs{Node: "n1", State: state,
|
||||
JudgeAccess: func(act, access string) (link.Judged, error) {
|
||||
return link.Judged{Subject: access, Path: root, Sets: "the group mesh-media", Disk: "0:0 755"}, nil
|
||||
},
|
||||
AccessAct: func(link.AccessAct) (string, error) { return "taken", nil }}
|
||||
h.Asker = &occ.Client{Module: occ.EngineAsker("n1"), State: state,
|
||||
Publish: func(subject string, body any, id string) error {
|
||||
if a, ok := body.(oc.Ask); ok {
|
||||
asked = append(asked, a)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
waiting := keepWaiting(vs)
|
||||
askTakes(h, waiting, nil)
|
||||
askTakes(h, waiting, nil)
|
||||
if len(waiting) != 1 || waiting[0] != "library:media" || len(asked) != 1 {
|
||||
t.Fatalf("waiting %v asked %d times", waiting, len(asked))
|
||||
}
|
||||
h.Heard(oc.Warrant{Ask: asked[0].ID, Asker: occ.EngineAsker("n1"), Outcome: occ.OutcomeExpired, At: occ.FormatTime(time.Now())})
|
||||
askTakes(h, waiting, nil)
|
||||
if len(asked) != 1 {
|
||||
t.Fatalf("the take was asked again after its ask ended: %d", len(asked))
|
||||
}
|
||||
// Kept in a key of its own (rev-l3 round 1): the acts gone from the record never make it ask again.
|
||||
keys, _ := state.Keys("act.")
|
||||
for _, k := range keys {
|
||||
_ = state.Delete(k)
|
||||
}
|
||||
askTakes(h, waiting, nil)
|
||||
if len(asked) != 1 {
|
||||
t.Fatalf("the take was asked again with the acts gone: %d", len(asked))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/outward"
|
||||
"github.com/novox/mesh-host/internal/profile"
|
||||
)
|
||||
|
||||
func reportOn(networks ...outward.Network) link.Report {
|
||||
return link.Report{Outward: []string{"eth0"},
|
||||
Profile: profileAsReported(profile.Profile{Machine: &profile.Machine{Networks: networks}})}
|
||||
}
|
||||
|
||||
// **A change of the networks a machine is attached to is said unasked** (novox/hq ADR 0340 §7): a port
|
||||
// reached from the local network is admitted from them, so a laptop that joins another network is said at
|
||||
// the next reconcile, not at the next push — and the same networks again are not news.
|
||||
func TestAChangedNetworkIsSaidUnasked(t *testing.T) {
|
||||
w := &adoptionWatch{}
|
||||
home := reportOn(outward.Network{Link: "eth0", Network: "192.168.1.0/24"})
|
||||
w.said(home)
|
||||
if w.differs(reportOn(outward.Network{Link: "eth0", Network: "192.168.1.0/24"})) {
|
||||
t.Fatal("the same networks read as a change, so the machine would speak on every reconcile")
|
||||
}
|
||||
if !w.differs(reportOn(outward.Network{Link: "eth0", Network: "10.20.0.0/16"})) {
|
||||
t.Fatal("a machine that moved to another network does not say so until it is pushed to")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
// loginsFile is where this engine keeps the logins it holds between runs (novox/hq ADR 0583 §1): beside its identity, readable by
|
||||
// its owner alone, as the identity's membership password is.
|
||||
func loginsFile(state string) string {
|
||||
return filepath.Join(filepath.Dir(identity.Path(state)), "logins.json")
|
||||
}
|
||||
|
||||
// loadLogins is the engine's logins: what it kept, with the membership's password as the first login's.
|
||||
func loadLogins(state, node, first string) *link.Logins {
|
||||
path := loginsFile(state)
|
||||
var saved link.LoginsState
|
||||
if raw, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(raw, &saved)
|
||||
}
|
||||
return link.NewLogins(node, first, saved, func(s link.LoginsState) error { return writeLogins(path, s) })
|
||||
}
|
||||
|
||||
func writeLogins(path string, s link.LoginsState) error {
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// loginsFollowAgain bounds the wait before a watch of node-logins that could not be made is tried again on the same link:
|
||||
// the vault may not serve rotation yet, and until it does this node is not granted the read.
|
||||
var loginsFollowAgain = 2 * time.Minute
|
||||
|
||||
// followLogins follows this node's key of the vault's node-logins on every link, as a watch (never a poll), for as long as ctx
|
||||
// lasts, and hands each value to hear. A watch that cannot be made is said once and tried again.
|
||||
func followLogins(ctx context.Context, session *link.Session, node string, hear func(meshvault.NodeLoginsByNodeValue), say link.Announce) {
|
||||
said := ""
|
||||
for ctx.Err() == nil {
|
||||
linked, cancel, err := session.WhenLinked(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
watching, stop := context.WithCancel(linked)
|
||||
updates, werr := session.KVWatch(watching, buckets.NodeLogins, node)
|
||||
if werr != nil {
|
||||
stop()
|
||||
if line := "this node's login is not followed yet (" + werr.Error() + "): it dials as it last did, and looks again in " + loginsFollowAgain.String(); line != said {
|
||||
said = line
|
||||
say(line)
|
||||
}
|
||||
} else {
|
||||
said = ""
|
||||
for u := range updates {
|
||||
if u.Caught || u.Deleted || len(u.Value) == 0 {
|
||||
continue
|
||||
}
|
||||
nl, err := meshvaultclient.DecodeNodeLogin(u.Value)
|
||||
if err != nil {
|
||||
say("the key " + node + " of " + buckets.NodeLoginsRead + " is not read: " + err.Error())
|
||||
continue
|
||||
}
|
||||
hear(nl)
|
||||
}
|
||||
stop()
|
||||
}
|
||||
select {
|
||||
case <-linked.Done():
|
||||
case <-time.After(loginsFollowAgain):
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// loginSecretModule is the module the vault names this engine's login values under: own/<node>/mesh-host/<login> (ADR 0583 §3,
|
||||
// insight).
|
||||
const loginSecretModule = "mesh-host"
|
||||
|
||||
// fetchLogin asks the vault, through its existing hand-out of a machine's own secrets, for the value of login, sealed to this
|
||||
// node's key, and opens it with the sealing key; no new verb is asked for it.
|
||||
func fetchLogin(queue *link.Session, statePath string) func(context.Context, string) (string, error) {
|
||||
unseal := sealOpener(statePath)
|
||||
return func(ctx context.Context, login string) (string, error) {
|
||||
raw, err := queue.AskVault(ctx, "own", map[string]any{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var a struct {
|
||||
Own []struct {
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
Sealed json.RawMessage `json:"sealed"`
|
||||
} `json:"own"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("the vault's own answer is not one: %w", err)
|
||||
}
|
||||
for _, o := range a.Own {
|
||||
if o.Module != loginSecretModule || o.Name != login {
|
||||
continue
|
||||
}
|
||||
sealed := string(o.Sealed)
|
||||
var text string
|
||||
if json.Unmarshal(o.Sealed, &text) == nil {
|
||||
sealed = text
|
||||
}
|
||||
value, err := unseal(sealed)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the value of %s cannot be opened with this node's sealing key: %w", login, err)
|
||||
}
|
||||
return string(value), nil
|
||||
}
|
||||
return "", fmt.Errorf("the vault hands this node no value for %s (own/%s/%s/%s)", login, queueNode(queue), loginSecretModule, login)
|
||||
}
|
||||
}
|
||||
|
||||
func queueNode(q *link.Session) string { return q.Membership.Node }
|
||||
|
||||
// newRotator is the engine's side of a rotation over this session (ADR 0583 §3-§6): the value fetched through the vault's own
|
||||
// hand-out, the test-dial on a second connection, the confirmation over the new link, and a fall back carried in the health
|
||||
// report.
|
||||
func newRotator(queue *link.Session, statePath string, timeout time.Duration, say link.Announce) *link.Rotator {
|
||||
r := &link.Rotator{Logins: queue.Membership.Logins, Say: say,
|
||||
Fetch: fetchLogin(queue, statePath),
|
||||
TestDial: func(ctx context.Context, login, password string) error {
|
||||
return link.TestDial(ctx, queue.Membership, login, password, timeout)
|
||||
},
|
||||
Confirm: func(ctx context.Context) error {
|
||||
login, _ := queue.Membership.Logins.Current()
|
||||
if err := queue.WhenLinkedAs(ctx, login); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := queue.AskVault(ctx, "node-account-confirm", map[string]any{})
|
||||
return err
|
||||
}}
|
||||
r.Watch()
|
||||
// The fall back is carried in this node's health report, from which the watchman raises node-account-fallback (ADR 0583 §6).
|
||||
queue.Fallback = r.Fallback
|
||||
return r
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
// Defends novox/hq issue 160: a machine nobody pushed to still says what it is made of once a day, so the
|
||||
// newest of it the mesh holds is no older than a day. Before, the reconcile said it only with something
|
||||
// else (a hold, a firewall, an outward link changed), and a converged machine's picture was as old as the
|
||||
// last delivery to it.
|
||||
func TestAMachineNobodyPushedToSaysWhatItIsOnceADay(t *testing.T) {
|
||||
at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
|
||||
w := &adoptionWatch{now: func() time.Time { return at }}
|
||||
quiet := link.Report{Outward: []string{"wlan0"},
|
||||
Profile: map[string]any{"machine": map[string]any{"observed_at": at}}}
|
||||
|
||||
if !w.news(quiet) {
|
||||
t.Fatal("the first reconcile after the engine started did not say what the machine is")
|
||||
}
|
||||
w.said(quiet)
|
||||
|
||||
for _, after := range []time.Duration{5 * time.Minute, 12 * time.Hour, 22*time.Hour + 55*time.Minute} {
|
||||
at = time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC).Add(after)
|
||||
if w.news(quiet) {
|
||||
t.Errorf("%s after the mesh heard it, an unchanged reconcile was said again", after)
|
||||
}
|
||||
}
|
||||
// The reconcile that falls past 23 hours says it, so it reaches the mesh inside the day.
|
||||
at = time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC).Add(23*time.Hour + 5*time.Minute)
|
||||
if !w.news(quiet) {
|
||||
t.Fatal("a day on, the reconcile did not say what the machine is")
|
||||
}
|
||||
w.said(quiet)
|
||||
at = at.Add(5 * time.Minute)
|
||||
if w.news(quiet) {
|
||||
t.Error("once said, it was said again at the next reconcile")
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery's account carries the profile too: heard, it starts the day again.
|
||||
func TestADeliveryHeardStartsTheDayAgain(t *testing.T) {
|
||||
at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
|
||||
w := &adoptionWatch{now: func() time.Time { return at }}
|
||||
report := link.Report{Outward: []string{"eth0"}, Profile: map[string]any{"capabilities": []any{}}}
|
||||
w.said(report)
|
||||
at = at.Add(20 * time.Hour)
|
||||
w.said(report) // a delivery's apply
|
||||
at = at.Add(20 * time.Hour)
|
||||
if w.news(report) {
|
||||
t.Error("said again unasked 20 hours after a delivery told the mesh")
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal carries nothing about the machine, so it is never said for being a day old.
|
||||
func TestARefusalIsNotSaidForTheMachinesSake(t *testing.T) {
|
||||
at := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
|
||||
w := &adoptionWatch{now: func() time.Time { return at }}
|
||||
if w.machineDue(link.Report{Refused: "no"}) {
|
||||
t.Error("a refusal was due for what the machine is made of")
|
||||
}
|
||||
}
|
||||
|
||||
// A laptop that slept still says what it is within a day of wall time (review of mesh-host#92). Go's monotonic
|
||||
// clock stops while a machine is suspended, and Sub between two times that both carry a monotonic reading uses
|
||||
// it, so a watch that kept one would count a day asleep as almost nothing. Simulated with a clock that first
|
||||
// reads now (a monotonic reading), and after the sleep reads the wall time a day on: the watch must keep no
|
||||
// monotonic reading, and judge the day as the wall clock says it.
|
||||
func TestALaptopThatSleptStillSaysWhatItIsWithinADay(t *testing.T) {
|
||||
before := time.Now() // carries a monotonic reading
|
||||
asleep := before.Add(3 * time.Second)
|
||||
if asleep.Sub(before) != 3*time.Second {
|
||||
t.Fatal("the monotonic reading is not what this test simulates with")
|
||||
}
|
||||
calls := 0
|
||||
w := &adoptionWatch{now: func() time.Time {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return before
|
||||
}
|
||||
// The wall clock a day on, as a machine's clock reads after waking.
|
||||
return asleep.Round(0).Add(24 * time.Hour)
|
||||
}}
|
||||
report := link.Report{Outward: []string{"wlan0"}, Profile: map[string]any{"capabilities": []any{}}}
|
||||
w.said(report)
|
||||
if strings.Contains(w.machineSaid.String(), "m=") {
|
||||
t.Fatalf("the watch kept a monotonic reading (%s): a sleep would not count toward the day", w.machineSaid)
|
||||
}
|
||||
if !w.news(report) {
|
||||
t.Error("a day of wall time on, a laptop that slept did not say what it is")
|
||||
}
|
||||
}
|
||||
|
||||
// And the watch's own clock never carries a monotonic reading.
|
||||
func TestTheWatchClockIsWallTime(t *testing.T) {
|
||||
w := &adoptionWatch{}
|
||||
if s := w.clock().String(); strings.Contains(s, "m=") {
|
||||
t.Errorf("the watch's clock carries a monotonic reading: %s", s)
|
||||
}
|
||||
w = &adoptionWatch{now: time.Now}
|
||||
if s := w.clock().String(); strings.Contains(s, "m=") {
|
||||
t.Errorf("a given clock's time kept its monotonic reading: %s", s)
|
||||
}
|
||||
}
|
||||
+433
-885
File diff suppressed because it is too large
Load Diff
+56
-295
@@ -3,19 +3,17 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/bundle"
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
@@ -98,26 +96,6 @@ func TestNoCommandIsNotAnError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyNeedsExactlyOneDeclaration(t *testing.T) {
|
||||
// `apply` takes a file where every other command takes nothing, so the leftover-argument
|
||||
// rule has an exception — and an exception is where a parser stops refusing things it
|
||||
// should. Both directions are checked.
|
||||
if _, _, err := parseArgs([]string{"apply"}); err == nil {
|
||||
t.Error("apply with no file was accepted")
|
||||
}
|
||||
if _, _, err := parseArgs([]string{"apply", "a.json", "b.json"}); err == nil {
|
||||
t.Error("apply with two files was accepted")
|
||||
}
|
||||
|
||||
command, opts, err := parseArgs([]string{"apply", "decl.json", "--dry-run"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if command != "apply" || opts.file != "decl.json" || !opts.dryRun {
|
||||
t.Errorf("parsed as command=%q file=%q dry-run=%v", command, opts.file, opts.dryRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStateHasADocumentedDefault(t *testing.T) {
|
||||
// A host that wrote its state somewhere unexpected would forget what it owns on the next
|
||||
// run, and then leave everything it had applied behind forever.
|
||||
@@ -135,97 +113,77 @@ func TestAFlagAfterAPositionalIsRead(t *testing.T) {
|
||||
// off the front fixed the flag after the COMMAND and not the flag after its ARGUMENT: the
|
||||
// standard library stops at the first non-flag argument wherever that argument is.
|
||||
for _, args := range [][]string{
|
||||
{"apply", "decl.json", "--dry-run", "--json"},
|
||||
{"apply", "--dry-run", "decl.json", "--json"},
|
||||
{"apply", "--dry-run", "--json", "decl.json"},
|
||||
{"hand-over", "decl.json", "--dry-run", "--json"},
|
||||
{"hand-over", "--dry-run", "decl.json", "--json"},
|
||||
{"hand-over", "--dry-run", "--json", "decl.json"},
|
||||
} {
|
||||
command, opts, err := parseArgs(args)
|
||||
if err != nil {
|
||||
t.Errorf("%v: unexpected error: %v", args, err)
|
||||
continue
|
||||
}
|
||||
if command != "apply" || opts.file != "decl.json" || !opts.dryRun || !opts.json {
|
||||
if command != "hand-over" || opts.file != "decl.json" || !opts.dryRun || !opts.json {
|
||||
t.Errorf("%v parsed as file=%q dry-run=%v json=%v",
|
||||
args, opts.file, opts.dryRun, opts.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq ADR 0100: a reconcile on an adopted node speaks unasked only when what it holds
|
||||
// or its firewall changed — which is how a predecessor still writing is caught, without a report
|
||||
// every five minutes saying nothing new.
|
||||
func TestAReconcileSpeaksOnlyWhenWhatIsHeldChanged(t *testing.T) {
|
||||
w := &adoptionWatch{}
|
||||
held := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.page"}, {ID: "hello-web.server"}}}
|
||||
if !w.changed(held) {
|
||||
t.Fatal("the first report of a hold was not said")
|
||||
}
|
||||
again := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.server"}, {ID: "hello-web.page"}}}
|
||||
if w.changed(again) {
|
||||
t.Error("the same holds in another order were said again")
|
||||
}
|
||||
rewritten := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.page", Changed: "rewritten"}, {ID: "hello-web.server"}}}
|
||||
if !w.changed(rewritten) {
|
||||
t.Error("a held file rewritten by something else was not said")
|
||||
}
|
||||
if !w.changed(link.Report{Firewall: "none", Held: rewritten.Held}) {
|
||||
t.Error("a changed firewall was not said")
|
||||
}
|
||||
// What filters the machine is part of it (novox/hq ADR 0168): a predecessor's chain removed by
|
||||
// hand, or the found firewall enabled again, is said without being asked.
|
||||
filtered := link.Report{Firewall: "none", Held: rewritten.Held,
|
||||
Filters: []link.Filter{{Where: "chain HAL-MESH-ONLY (iptables-legacy)", Owner: "other", Refuses: "-j DROP"}}}
|
||||
if !w.changed(filtered) {
|
||||
t.Error("a filter appearing was not said")
|
||||
}
|
||||
if !w.changed(link.Report{Firewall: "none", Held: rewritten.Held}) {
|
||||
t.Error("a filter removed was not said")
|
||||
}
|
||||
if !w.changed(link.Report{Firewall: "none", Held: rewritten.Held, FoundFirewall: &link.FoundFirewall{Kind: "ufw", Active: true}}) {
|
||||
t.Error("the found firewall coming back was not said")
|
||||
}
|
||||
if !worthSaying(link.Report{Filters: filtered.Filters}) {
|
||||
t.Error("a report carrying only what filters the machine is not worth saying")
|
||||
}
|
||||
}
|
||||
|
||||
// What the mesh heard — a delivery's account as much as a reconcile's — counts as said: the queue
|
||||
// tells the watch every report the broker took (link.Queue.Heard).
|
||||
func TestWhatTheMeshHeardCountsAsSaid(t *testing.T) {
|
||||
w := &adoptionWatch{}
|
||||
report := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "a"}}}
|
||||
heard := w.said
|
||||
heard(report)
|
||||
if w.changed(report) {
|
||||
t.Error("a reconcile repeated what the mesh had just heard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReconcileSpeaksWhenWhatIsReachableChanged(t *testing.T) {
|
||||
// The controller previews a flip from what the node last said is reachable; a port that opened
|
||||
// since must reach it without waiting for the next delivery (novox/hq ADR 0100).
|
||||
w := &adoptionWatch{}
|
||||
before := link.Report{Firewall: "ufw", Reachable: []link.Reach{
|
||||
{Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"}}}
|
||||
if !w.changed(before) {
|
||||
t.Fatal("the first report was not said")
|
||||
}
|
||||
reordered := link.Report{Firewall: "ufw", Reachable: []link.Reach{
|
||||
{Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"}}}
|
||||
if w.changed(reordered) {
|
||||
t.Error("the same reachable set was said again")
|
||||
}
|
||||
opened := link.Report{Firewall: "ufw", Reachable: append(before.Reachable,
|
||||
link.Reach{Protocol: "tcp", Address: "0.0.0.0", Port: 8080, By: "hello-web", Published: true, ContainerPort: 80})}
|
||||
if !w.changed(opened) {
|
||||
t.Error("a newly published port was not said")
|
||||
}
|
||||
}
|
||||
|
||||
// Defends the node's own record: the link and the reconcile loop both apply, and each reads the
|
||||
// state, acts, and writes it back — so they must not run at the same time, or the last save loses
|
||||
// what the other recorded.
|
||||
func TestOnlyOneApplyRunsAtATime(t *testing.T) {
|
||||
// Issue 1585: the engine's stop waits for the apply in flight to end and save, and then lets no save start; before,
|
||||
// the process ended under it and a save cut before its rename left its temporary file and the state before it.
|
||||
t.Run("the stop waits for the apply in flight and its save", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
applying.Lock()
|
||||
var said []string
|
||||
var mu sync.Mutex
|
||||
waiting := make(chan struct{})
|
||||
say := func(line string) {
|
||||
mu.Lock()
|
||||
said = append(said, line)
|
||||
mu.Unlock()
|
||||
if strings.Contains(line, "waiting up to") {
|
||||
close(waiting)
|
||||
}
|
||||
}
|
||||
stopped := make(chan func(), 1)
|
||||
go func() { stopped <- stopBetweenApplies(say, time.Hour) }()
|
||||
// Said before it blocks, and it cannot end before the apply lets go: so it has not ended now.
|
||||
<-waiting
|
||||
select {
|
||||
case release := <-stopped:
|
||||
release()
|
||||
applying.Unlock()
|
||||
t.Fatal("the stop did not wait for the apply in flight")
|
||||
default:
|
||||
}
|
||||
if err := store.Save(path, store.State{Resources: []store.Applied{{ID: "nats.program-snapshot", Type: "archive"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applying.Unlock()
|
||||
release := <-stopped
|
||||
saved := make(chan error, 1)
|
||||
go func() { saved <- store.Save(path, store.State{}) }()
|
||||
// A save after the stop blocks; nothing says it blocks, so this one wait is a bounded look.
|
||||
select {
|
||||
case <-saved:
|
||||
release()
|
||||
t.Fatal("a save started after the stop")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
if err := <-saved; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(said) != 2 || !strings.Contains(said[0], "waiting up to 1h0m0s") || !strings.Contains(said[1], "its state is saved") {
|
||||
t.Errorf("said %q", said)
|
||||
}
|
||||
})
|
||||
// A host is built for one system at link time, and a test binary has no link time: this asks
|
||||
// the machine it runs on, and stands aside where the answer is no.
|
||||
built, err := system.For("arch")
|
||||
@@ -270,24 +228,6 @@ func TestOnlyOneApplyRunsAtATime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq ADR 0100: a change is counted as said only once the mesh has been told. Queued
|
||||
// and lost — the link down when the reconcile spoke — it must be said again.
|
||||
func TestAChangeThatNeverReachedTheMeshIsSaidAgain(t *testing.T) {
|
||||
w := &adoptionWatch{}
|
||||
held := link.Report{Firewall: "ufw", Held: []link.Held{{ID: "hello-web.page", Changed: "rewritten"}}}
|
||||
if !w.differs(held) {
|
||||
t.Fatal("the first report of a change was not new")
|
||||
}
|
||||
// The link was down: nothing published it, so nothing says it was said.
|
||||
if !w.differs(held) {
|
||||
t.Error("a change that never reached the mesh was counted as said")
|
||||
}
|
||||
w.said(held)
|
||||
if w.differs(held) {
|
||||
t.Error("a change the mesh was told was said again")
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issue 104: a declaration for the other mode than this node is in is refused at
|
||||
// the point of application, whichever command delivered it, naming both — an adopted control-node
|
||||
// once applied its converged genesis bundle and closed itself for forty-five minutes.
|
||||
@@ -468,125 +408,6 @@ func TestADryRunChangesNothingAndListsTheActions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issue 104: once the mesh has told this node anything, `reconcile` holds it to
|
||||
// that — never to the bundle the host carries, which genesis consumed.
|
||||
func TestReconcileAfterAControllerDeclarationDoesNotReapplyTheBundle(t *testing.T) {
|
||||
was := builtFor
|
||||
builtFor = "arch"
|
||||
t.Cleanup(func() { builtFor = was })
|
||||
opts := stateWithMode(t, store.ModeAdopted)
|
||||
|
||||
controllerPublic, controllerPrivate, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said := []byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
|
||||
if err := store.SaveDeclared(store.DeclaredPath(opts.state), store.Declared{Declaration: said,
|
||||
Signature: ed25519.Sign(controllerPrivate, said)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Told, and unable to prove by whom: refused, and the bundle is not applied in its place.
|
||||
_, _, _, err = reconcileSource(opts)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applied in its place") {
|
||||
t.Errorf("a node that cannot prove what it was told fell back to something: %v", err)
|
||||
}
|
||||
|
||||
mine, err := identity.Generate("workstation")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine.Membership = identity.Membership{Broker: "198.51.100.10:5671", Fingerprint: "sha256:0",
|
||||
Signer: controllerPublic, Password: "issued"}
|
||||
if err := identity.Save(identity.Path(opts.state), mine); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, raw, from, err := reconcileSource(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if from != fromDeclared || !bytes.Equal(raw, said) || d.Adoption == nil {
|
||||
t.Errorf("reconcile chose %d with %d bytes, not what the mesh last said", from, len(raw))
|
||||
}
|
||||
|
||||
// And before the mesh has said anything: the bundle, as it always was. A test binary carries
|
||||
// only the placeholder, and asking for it is what proves the path.
|
||||
if err := os.Remove(store.DeclaredPath(opts.state)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, _, err = reconcileSource(opts)
|
||||
if !errors.Is(err, bundle.ErrEmpty) {
|
||||
t.Errorf("with nothing said, reconcile did not reach for the carried bundle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A machine says which links face outside without being asked.**
|
||||
//
|
||||
// The mesh composes no filter for a machine that has not said (novox/hq ADR 0140), and a machine only
|
||||
// speaks unasked when this fingerprint changes. Left out of it, a machine that has just learnt to say
|
||||
// could speak only when a declaration arrived — and a declaration cannot be composed until it has
|
||||
// spoken. A machine waiting for a push that is waiting for the machine.
|
||||
func TestANewOutwardLinkIsSaidUnasked(t *testing.T) {
|
||||
w := &adoptionWatch{}
|
||||
first := link.Report{Firewall: "none"}
|
||||
if !w.differs(first) {
|
||||
t.Fatal("the first report should differ from nothing")
|
||||
}
|
||||
w.said(first)
|
||||
|
||||
// Only the links changed, and nothing about adoption.
|
||||
learnt := link.Report{Firewall: "none", Outward: []string{"eth0"}}
|
||||
if !w.differs(learnt) {
|
||||
t.Fatal("a machine that has just learnt which links face outside would never say so, " +
|
||||
"and could then never be sent a filter")
|
||||
}
|
||||
w.said(learnt)
|
||||
if w.differs(link.Report{Firewall: "none", Outward: []string{"eth0"}}) {
|
||||
t.Fatal("the same links are reported as a change, so the machine would speak on every reconcile")
|
||||
}
|
||||
|
||||
// And a link that changes — a laptop moving from a cable to a radio — is said too, because the
|
||||
// filter is written around the old one until it is.
|
||||
if !w.differs(link.Report{Firewall: "none", Outward: []string{"wlan0"}}) {
|
||||
t.Fatal("a changed outward link is not said, so the filter stays written around the old one")
|
||||
}
|
||||
}
|
||||
|
||||
// **A converged machine can say which links face outside, unasked.**
|
||||
//
|
||||
// A reconcile is otherwise silent, and the condition deciding when it speaks asked only what an
|
||||
// adopted node reports — what it holds, and the firewall it found. A converged node has neither, so
|
||||
// it could speak only in reply to a declaration, and the mesh composes no declaration for a node
|
||||
// that has not said which links face outside (novox/hq ADR 0140). Measured: three converged machines
|
||||
// sat silent while the control plane refused to send them a filter.
|
||||
func TestAConvergedMachineSaysItsOutwardLinksUnasked(t *testing.T) {
|
||||
// A converged node's reconcile: nothing held, no found firewall, and the links it can see.
|
||||
converged := link.Report{Outward: []string{"eth0"}}
|
||||
if !worthSaying(converged) {
|
||||
t.Fatal("a converged machine cannot say which links face outside, so it can never be " +
|
||||
"sent a filter — a machine waiting for a push that is waiting for the machine")
|
||||
}
|
||||
|
||||
// An adopted node's reasons still hold, because that is how a predecessor still writing is caught.
|
||||
if !worthSaying(link.Report{Firewall: "ufw"}) {
|
||||
t.Fatal("an adopted machine no longer says which firewall it found")
|
||||
}
|
||||
if !worthSaying(link.Report{Held: []link.Held{{ID: "a-file"}}}) {
|
||||
t.Fatal("an adopted machine no longer says what it holds")
|
||||
}
|
||||
|
||||
// And a reconcile with nothing to say stays silent, or every machine speaks every five minutes
|
||||
// about nothing.
|
||||
if worthSaying(link.Report{}) {
|
||||
t.Fatal("a reconcile with nothing to say speaks anyway")
|
||||
}
|
||||
|
||||
// A refused report says nothing about the machine; the refusal is for the console.
|
||||
if worthSaying(link.Report{Outward: []string{"eth0"}, Refused: "not for this node"}) {
|
||||
t.Fatal("a refused report is offered as news about the machine")
|
||||
}
|
||||
}
|
||||
|
||||
// **A host running as a service says what its apply did.**
|
||||
//
|
||||
// The serving path passed nil where the apply writes its detail, and nil is silence. The one-shot path
|
||||
@@ -611,63 +432,3 @@ func TestTheApplysLogIsNeverNil(t *testing.T) {
|
||||
t.Fatalf("the apply's detail did not reach the caller's announce: %v", said)
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issues 257 and 261: a reconcile due while the link applies a newer declaration
|
||||
// applies that newer one once its turn comes — never the one kept when its timer fired. Read before
|
||||
// waiting, it gave back a module assigned a second earlier, and reported a declaration the mesh no
|
||||
// longer recorded as sent.
|
||||
func TestAReconcileAppliesWhatWasKeptWhenItsTurnComes(t *testing.T) {
|
||||
built, err := system.For("arch")
|
||||
if err != nil || built.Confirm(context.Background(), apply.ExecRunner) != nil {
|
||||
t.Skip("this machine is not one these tests can apply on")
|
||||
}
|
||||
was := builtFor
|
||||
builtFor = "arch"
|
||||
t.Cleanup(func() { builtFor = was })
|
||||
dir := t.TempDir()
|
||||
opts := options{state: filepath.Join(dir, "state.json")}
|
||||
public, private, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(dir, "a.conf")
|
||||
kept := func(content string) store.Declared {
|
||||
body := []byte(`{"declaration":1,"resources":[{"id":"a","type":"file","path":"` + target +
|
||||
`","content":"` + content + `\n"}]}`)
|
||||
return store.Declared{Declaration: body, Signature: ed25519.Sign(private, body)}
|
||||
}
|
||||
if err := store.SaveDeclared(store.DeclaredPath(opts.state), kept("older")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The link is applying: the reconcile's timer fires and it waits its turn.
|
||||
applying.Lock()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := reapplyKept(context.Background(), opts, public, nil, nil)
|
||||
done <- err
|
||||
}()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
// The link's apply ends by keeping the newer declaration, and lets go.
|
||||
if err := store.SaveDeclared(store.DeclaredPath(opts.state), kept("newer")); err != nil {
|
||||
applying.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
applying.Unlock()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the reconcile never ran once the node was free")
|
||||
}
|
||||
got, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "newer\n" {
|
||||
t.Errorf("the reconcile applied %q, what was kept before the newer declaration", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// markWithin bounds one marking of the haves (what the composer tells its agent: a refusal, the words it keeps). The marks
|
||||
// read and write each module's have over the bus one after the other; a bus that answers slowly or not at all must hold
|
||||
// the marking, never the composition pass (issue 1965, shanks 2026-11-05 22:22: a pass waited minutes on them).
|
||||
var markWithin = 45 * time.Second
|
||||
|
||||
// marker runs the composer's marks of the haves off the composition pass: set returns at once, one worker runs what was
|
||||
// set last under each name, in the order the names were first set, each within markWithin. A mark set while one runs
|
||||
// replaces the pending mark of its name, so the haves are marked once for the newest word, not once for every pass.
|
||||
type marker struct {
|
||||
run context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]func(context.Context)
|
||||
order []string
|
||||
working bool
|
||||
idle *sync.Cond
|
||||
}
|
||||
|
||||
func newMarker(run context.Context) *marker {
|
||||
m := &marker{run: run, pending: map[string]func(context.Context){}}
|
||||
m.idle = sync.NewCond(&m.mu)
|
||||
return m
|
||||
}
|
||||
|
||||
// set takes the mark name should carry; it never waits on the bus.
|
||||
func (m *marker) set(name string, mark func(context.Context)) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, queued := m.pending[name]; !queued {
|
||||
m.order = append(m.order, name)
|
||||
}
|
||||
m.pending[name] = mark
|
||||
if m.working {
|
||||
return
|
||||
}
|
||||
m.working = true
|
||||
go m.work()
|
||||
}
|
||||
|
||||
func (m *marker) work() {
|
||||
for {
|
||||
m.mu.Lock()
|
||||
if len(m.order) == 0 || m.run.Err() != nil {
|
||||
m.order, m.pending = nil, map[string]func(context.Context){}
|
||||
m.working = false
|
||||
m.idle.Broadcast()
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
name := m.order[0]
|
||||
m.order = m.order[1:]
|
||||
mark := m.pending[name]
|
||||
delete(m.pending, name)
|
||||
m.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(m.run, markWithin)
|
||||
mark(ctx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// wait returns once no mark is pending or running (for tests).
|
||||
func (m *marker) wait() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for m.working {
|
||||
m.idle.Wait()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue 1965 (shanks 2026-11-05 22:22): the marks of the haves never hold the composition pass. A mark that stalls on the
|
||||
// bus is cut at markWithin, a mark set while one runs replaces the pending one of its name, and set itself never waits.
|
||||
func marksOfTheHavesNeverHoldThePass(t *testing.T) {
|
||||
// The bound is the test's own: the stalled mark is released by a channel, never by a real wait.
|
||||
setMarkWithin(t, time.Hour)
|
||||
m := newMarker(context.Background())
|
||||
release := make(chan struct{})
|
||||
var ran []string
|
||||
stalled := make(chan struct{})
|
||||
m.set("refused", func(ctx context.Context) { // a have write that stalls until the test lets it go
|
||||
close(stalled)
|
||||
<-release
|
||||
ran = append(ran, "refused-stalled")
|
||||
})
|
||||
<-stalled
|
||||
// Set while the first runs: only the newest of a name is kept.
|
||||
m.set("kept", func(context.Context) { ran = append(ran, "kept-old") })
|
||||
m.set("kept", func(context.Context) { ran = append(ran, "kept-new") })
|
||||
m.set("refused", func(context.Context) { ran = append(ran, "refused-new") })
|
||||
// All four sets above returned while the first mark still stalled: set never waits on it.
|
||||
close(release)
|
||||
m.wait()
|
||||
want := []string{"refused-stalled", "kept-new", "refused-new"}
|
||||
if len(ran) != len(want) {
|
||||
t.Fatalf("ran %v; want %v", ran, want)
|
||||
}
|
||||
for i := range want {
|
||||
if ran[i] != want[i] {
|
||||
t.Fatalf("ran %v; want %v", ran, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setMarkWithin(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
old := markWithin
|
||||
markWithin = d
|
||||
t.Cleanup(func() { markWithin = old })
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/cli"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/meshcli"
|
||||
)
|
||||
|
||||
// serveMeshCLI answers mesh-cli on this machine for as long as this engine serves (novox/hq ADR 0272): the account
|
||||
// from the kernel, the line asked of the controller over the link open at the time. A socket that cannot be served
|
||||
// is said once, and this engine goes on applying; mesh-cli then says the engine does not answer.
|
||||
func serveMeshCLI(ctx context.Context, queue *link.Queue, say func(string)) {
|
||||
if err := meshcli.Serve(ctx, cli.Socket, forwardOver(queue), say); err != nil {
|
||||
say("mesh-cli is not answered on this machine: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// forwardOver asks the controller over the queue's link, and follows the line to what came of it.
|
||||
func forwardOver(queue *link.Queue) meshcli.Forward {
|
||||
return func(ctx context.Context, asked meshcli.Asked) cli.Answer {
|
||||
return meshcli.AskTheController(ctx, queue.AskController, asked)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
@@ -27,7 +28,7 @@ type vaultAsk func(verb string, args any) (json.RawMessage, error)
|
||||
|
||||
// moveSecretFrom is the engine's move: this machine's sealing key, read for each ask, and the vault asked over
|
||||
// the link open now.
|
||||
func moveSecretFrom(statePath string, queue *link.Queue) link.MoveSecretFunc {
|
||||
func moveSecretFrom(statePath string, queue *link.Session) link.MoveSecretFunc {
|
||||
return func(m link.MoveSecret) (link.MovedSecret, error) {
|
||||
key, err := identity.LoadSealingKey(identity.SealingKeyPath(statePath))
|
||||
if err != nil {
|
||||
@@ -40,6 +41,11 @@ func moveSecretFrom(statePath string, queue *link.Queue) link.MoveSecretFunc {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mesh-host: a value was not moved into the vault: %v\n", err)
|
||||
}
|
||||
if moved.Superseded != "" {
|
||||
if _, said := supersededSaid.LoadOrStore(moved.Secret, true); !said {
|
||||
fmt.Fprintf(os.Stderr, "mesh-host: %s\n", moved.Superseded)
|
||||
}
|
||||
}
|
||||
return moved, err
|
||||
}
|
||||
}
|
||||
@@ -104,8 +110,30 @@ func moveIntoTheVault(ask vaultAsk, key identity.SealingKey, m link.MoveSecret)
|
||||
Generation int `json:"generation"`
|
||||
Unchanged bool `json:"unchanged"`
|
||||
ReadBack string `json:"read_back"`
|
||||
// Refused is the vault's refusal as fields (mesh-vault's MoveRefused, mesh-catalog f3b13de2): nothing was taken.
|
||||
Refused *struct {
|
||||
Reason string `json:"reason"`
|
||||
Held *heldValue `json:"held"`
|
||||
} `json:"refused"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &kept); err != nil || kept.Name == "" || kept.ReadBack == "" {
|
||||
if err := json.Unmarshal(raw, &kept); err != nil {
|
||||
return link.MovedSecret{}, fmt.Errorf("the vault's answer for %s does not read: %v", opened.Secret, err)
|
||||
}
|
||||
if r := kept.Refused; r != nil {
|
||||
// The vault holds another value for the name, and the move never replaces one: the vault's value is the live
|
||||
// one, and this machine's older value is superseded, never asked again (the sweep of 2026-10-30: it failed
|
||||
// "answered with no read-back" on every ask).
|
||||
if r.Reason == vaultHoldsAnother && r.Held != nil && r.Held.Name != "" {
|
||||
return link.MovedSecret{Secret: r.Held.Name, Generation: r.Held.Generation, Superseded: r.Held.words()}, nil
|
||||
}
|
||||
why := kept.Why
|
||||
if why == "" {
|
||||
why = r.Reason
|
||||
}
|
||||
return link.MovedSecret{}, fmt.Errorf("the vault refused %s: %s", opened.Secret, why)
|
||||
}
|
||||
if kept.Name == "" || kept.ReadBack == "" {
|
||||
return link.MovedSecret{}, fmt.Errorf("the vault answered %s with no read-back", opened.Secret)
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(kept.ReadBack)
|
||||
@@ -124,6 +152,31 @@ func moveIntoTheVault(ask vaultAsk, key identity.SealingKey, m link.MoveSecret)
|
||||
return link.MovedSecret{Secret: kept.Name, Generation: kept.Generation, Unchanged: kept.Unchanged}, nil
|
||||
}
|
||||
|
||||
// vaultHoldsAnother is the vault's reason when it holds another value for the name (mesh-vault RefusedHeldAnother).
|
||||
const vaultHoldsAnother = "holds-another-value"
|
||||
|
||||
// heldValue is the record of the value the vault holds instead, never the value (mesh-vault HeldAnother).
|
||||
type heldValue struct {
|
||||
Name string `json:"name"`
|
||||
Generation int `json:"generation"`
|
||||
ChangedBy string `json:"changed_by"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
||||
// words say the held value's record, for the journal and the controller.
|
||||
func (h heldValue) words() string {
|
||||
w := fmt.Sprintf("the vault holds another value for %s, generation %d, changed by %s at %s", h.Name, h.Generation,
|
||||
h.ChangedBy, h.ChangedAt.UTC().Format(time.RFC3339))
|
||||
if h.Origin != "" {
|
||||
w += ", origin " + h.Origin
|
||||
}
|
||||
return w + ": that value is the live one, and this machine's older value is superseded and not moved"
|
||||
}
|
||||
|
||||
// supersededSaid holds the names a superseded move was said of, so the journal says each once.
|
||||
var supersededSaid sync.Map
|
||||
|
||||
func wipe(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
|
||||
@@ -17,13 +17,15 @@ import (
|
||||
// standInVault answers move-in's two steps as the vault does: a key made for the call, the value opened with it,
|
||||
// and the value kept read back sealed to the key the second step sent. lie makes its read-back another value.
|
||||
type standInVault struct {
|
||||
t *testing.T
|
||||
pub *[32]byte
|
||||
priv *[32]byte
|
||||
opened map[string]any
|
||||
kept string
|
||||
lie string
|
||||
refuse string
|
||||
t *testing.T
|
||||
pub *[32]byte
|
||||
priv *[32]byte
|
||||
opened map[string]any
|
||||
kept string
|
||||
lie string
|
||||
refuse string
|
||||
// answer replaces the second step's answer: the vault's refusal as fields (mesh-catalog f3b13de2).
|
||||
answer map[string]any
|
||||
carried []string
|
||||
}
|
||||
|
||||
@@ -51,6 +53,9 @@ func (v *standInVault) ask(verb string, args any) (json.RawMessage, error) {
|
||||
v.t.Fatal("what the engine sealed does not open with the call's key")
|
||||
}
|
||||
v.kept = string(value)
|
||||
if v.answer != nil {
|
||||
return json.Marshal(v.answer)
|
||||
}
|
||||
back := v.kept
|
||||
if v.lie != "" {
|
||||
back = v.lie
|
||||
@@ -119,6 +124,28 @@ func TestTheEngineSaysAMoveThatDidNotHold(t *testing.T) {
|
||||
!strings.Contains(err.Error(), "never replaces") {
|
||||
t.Fatalf("the vault's refusal: %v", err)
|
||||
}
|
||||
// The vault's refusal as fields (mesh-catalog f3b13de2, no read-back): for another value held, the vault's is the
|
||||
// live one and the move is done, superseded, at the held generation; any other reason fails in the vault's words.
|
||||
held := map[string]any{"name": "own/anchor/postgres/superuser-password", "generation": 3,
|
||||
"changed_by": "mesh-vault@novox", "changed_at": "2026-10-30T15:50:00Z", "origin": "made"}
|
||||
for _, row := range []struct {
|
||||
refused map[string]any
|
||||
gen int
|
||||
fails string
|
||||
}{
|
||||
{map[string]any{"reason": "holds-another-value", "held": held}, 3, ""},
|
||||
{map[string]any{"reason": "locked"}, 0, "the vault refused"},
|
||||
} {
|
||||
v := &standInVault{t: t, answer: map[string]any{"refused": row.refused, "why": "in words"}}
|
||||
moved, err := moveIntoTheVault(v.ask, key, aMoveOf(t, key))
|
||||
switch {
|
||||
case row.fails != "" && (err == nil || !strings.Contains(err.Error(), row.fails)):
|
||||
t.Errorf("refused %v: %v", row.refused["reason"], err)
|
||||
case row.fails == "" && (err != nil || moved.Generation != row.gen || moved.Secret != held["name"] ||
|
||||
!strings.Contains(moved.Superseded, "changed by mesh-vault@novox")):
|
||||
t.Errorf("refused %v: moved %+v, %v", row.refused["reason"], moved, err)
|
||||
}
|
||||
}
|
||||
// A pair credential names both its ends.
|
||||
pair := aMoveOf(t, key)
|
||||
pair.Module, pair.Name = "", ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R6-A AC8: nothing in the node-engine receives, orders or reconciles a whole declaration (novox/hq ADR 0539 §9, ADR
|
||||
// 0429 §8): no package subscribes to a node's declare subject or names the deleted symbols.
|
||||
func TestNoPackageHearsOrOrdersAWholeDeclaration(t *testing.T) {
|
||||
deleted := []string{
|
||||
"DeclareSubject", "mesh.node.\" + node + \".declare", "ReconcileDue", "ReconcileEvery", "olderThanKept",
|
||||
"refuseOlder", "olderGeneration", "appliedGeneration", "reconcileKept", "reapplyKept", "adoptionWatch",
|
||||
"unsaidFile", "numbersFile", "func gather(", "func handleBody(", "Supersedes(", "ReportSequence",
|
||||
"RefusedOlder", "ReadsGeneration", "ReadsModules", "GenerationRefusal", "LeftOutModuleOf",
|
||||
}
|
||||
root := filepath.Join("..", "..")
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The composition moved in from mesh-sdk (novox/hq ADR 0593 §7) has a node's input field of the same name,
|
||||
// ReadsModules, which says the node reads a module per resource; it is no reconciling of a declaration.
|
||||
if d.IsDir() && path == filepath.Join(root, "internal", "compose") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "nodeclare_test.go") {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, word := range deleted {
|
||||
if strings.Contains(string(body), word) {
|
||||
t.Errorf("%s still names %q", path, word)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
@@ -22,12 +25,58 @@ const (
|
||||
nodeKeyLook = 30 * time.Second
|
||||
)
|
||||
|
||||
// heardKey is the vault's own public key as its answer to node-key gave it (ADR 0547 clause 9, issue 1945): the key the
|
||||
// bus node hands the loaded list's hashes sealed to. One for the process, as the key stated is.
|
||||
type heardKey struct {
|
||||
mu sync.Mutex
|
||||
pem string
|
||||
}
|
||||
|
||||
// vaultKey is the vault's key this engine heard.
|
||||
var vaultKey = &heardKey{}
|
||||
|
||||
func (h *heardKey) get() string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.pem
|
||||
}
|
||||
|
||||
// heard keeps the key a node-key answer gave. An answer with no key (a vault before this one) changes nothing.
|
||||
func (h *heardKey) heard(answer json.RawMessage) {
|
||||
var a struct {
|
||||
VaultKey string `json:"vault_key"`
|
||||
}
|
||||
if json.Unmarshal(answer, &a) != nil || !strings.Contains(a.VaultKey, "PUBLIC KEY") {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.pem = a.VaultKey
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// nodeKeyReask is how often the key is stated again though it did not change, so a changed vault key is heard: the
|
||||
// vault takes the same key again and changes nothing (ADR 0547 clause 9).
|
||||
const nodeKeyReask = 10 * time.Minute
|
||||
|
||||
// keyHearing is what the keeper does with the vault's own key in node-key's answers (ADR 0547 clause 9): heard takes
|
||||
// it from an answer; held says whether one is held, and while none is the key is stated again at every look, so a vault
|
||||
// delivered after this engine is heard within a look; every is how often it is stated again all the same, so a changed
|
||||
// vault key is heard.
|
||||
type keyHearing struct {
|
||||
heard func(json.RawMessage)
|
||||
held func() bool
|
||||
// look is how often it is stated again while no vault key is held; every how often once one is.
|
||||
look, every time.Duration
|
||||
}
|
||||
|
||||
// keepNodeKeyStated states the sealing key load answers to the vault at start, retrying with a growing wait from
|
||||
// retryFrom to retryUpTo while it does not answer, and states it again whenever the key changes. It says one line
|
||||
// when a key is stated and one when a try fails for another reason than the last, and returns when ctx ends.
|
||||
// when a key is stated and one when a try fails for another reason than the last, and returns when ctx ends. With
|
||||
// hearing, each answer goes to it, and the key is stated again while no vault key is held and at its interval.
|
||||
func keepNodeKeyStated(ctx context.Context, load func() (identity.SealingKey, error), ask vaultAsk, say func(string),
|
||||
retryFrom, retryUpTo time.Duration) {
|
||||
retryFrom, retryUpTo time.Duration, hearing *keyHearing) {
|
||||
stated, lastFailure := "", ""
|
||||
var askedAt time.Time
|
||||
wait := retryFrom
|
||||
fail := func(why string) {
|
||||
if why != lastFailure {
|
||||
@@ -40,17 +89,29 @@ func keepNodeKeyStated(ctx context.Context, load func() (identity.SealingKey, er
|
||||
for {
|
||||
delay := look
|
||||
key, err := load()
|
||||
again := false
|
||||
if hearing != nil && stated != "" {
|
||||
since := time.Since(askedAt)
|
||||
held := hearing.held == nil || hearing.held()
|
||||
again = (!held && since >= hearing.look) || (held && hearing.every > 0 && since >= hearing.every)
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
fail(err.Error())
|
||||
delay = wait
|
||||
case key.Public != stated:
|
||||
if _, err := ask("node-key", map[string]string{"key": key.Public}); err != nil {
|
||||
case key.Public != stated || again:
|
||||
if answer, err := ask("node-key", map[string]string{"key": key.Public}); err != nil {
|
||||
fail(err.Error())
|
||||
delay = wait
|
||||
} else {
|
||||
askedAt = time.Now()
|
||||
if hearing != nil && hearing.heard != nil {
|
||||
hearing.heard(answer)
|
||||
}
|
||||
if key.Public != stated {
|
||||
say(fmt.Sprintf("mesh-host: this machine's sealing key %s is stated to the vault", key.Public))
|
||||
}
|
||||
stated, lastFailure, wait = key.Public, "", retryFrom
|
||||
say(fmt.Sprintf("mesh-host: this machine's sealing key %s is stated to the vault", key.Public))
|
||||
}
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -19,6 +19,10 @@ type keyVault struct {
|
||||
stated []string
|
||||
fail int
|
||||
verbs []string
|
||||
// keyless is how many answers carry no vault key (a vault before ADR 0547 clause 9's, or not yet started); key the
|
||||
// vault's key the others carry.
|
||||
keyless int
|
||||
key string
|
||||
}
|
||||
|
||||
func (v *keyVault) ask(verb string, args any) (json.RawMessage, error) {
|
||||
@@ -35,7 +39,12 @@ func (v *keyVault) ask(verb string, args any) (json.RawMessage, error) {
|
||||
}
|
||||
_ = json.Unmarshal(raw, &a)
|
||||
v.stated = append(v.stated, a.Key)
|
||||
return json.RawMessage(`{"recorded":true}`), nil
|
||||
if v.key == "" || v.keyless > 0 {
|
||||
v.keyless--
|
||||
return json.RawMessage(`{"recorded":true}`), nil
|
||||
}
|
||||
answer, _ := json.Marshal(map[string]any{"recorded": true, "vault_key": v.key})
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func (v *keyVault) said() []string {
|
||||
@@ -71,9 +80,14 @@ func TestTheEngineStatesItsSealingKeyToTheVaultAtStartRetriesAndRestatesAChange(
|
||||
say := func(l string) { lmu.Lock(); lines = append(lines, l); lmu.Unlock() }
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go keepNodeKeyStated(ctx, load, vault.ask, say, time.Millisecond, 5*time.Millisecond)
|
||||
var hmu sync.Mutex
|
||||
var answers []string
|
||||
heard := func(a json.RawMessage) { hmu.Lock(); answers = append(answers, string(a)); hmu.Unlock() }
|
||||
go keepNodeKeyStated(ctx, load, vault.ask, say, time.Millisecond, 5*time.Millisecond, &keyHearing{heard: heard})
|
||||
|
||||
waitFor(t, "the first key stated after two refusals", func() bool { return len(vault.said()) == 1 })
|
||||
// Its answer is handed on, so the vault's own key in it is heard (ADR 0547 clause 9); refusals hand nothing.
|
||||
waitFor(t, "the answer heard", func() bool { hmu.Lock(); defer hmu.Unlock(); return len(answers) == 1 })
|
||||
if vault.said()[0] != first.Public {
|
||||
t.Errorf("stated %q, want the engine's own key %q", vault.said()[0], first.Public)
|
||||
}
|
||||
@@ -102,11 +116,68 @@ func TestTheEngineStatesItsSealingKeyToTheVaultAtStartRetriesAndRestatesAChange(
|
||||
if len(lines) == 0 {
|
||||
t.Error("the engine said nothing")
|
||||
}
|
||||
asksAgainUntilItHearsTheVaultsKey(t)
|
||||
}
|
||||
|
||||
// The statement a host makes names this contract (issue 786), one above the vault's box.
|
||||
// ADR 0547 clause 9: the vault's key is heard though the engine's own key never changes. While none is held the key
|
||||
// is stated again at each look, so a vault delivered after the engine (or answering no key yet) is heard; once one is
|
||||
// held, again only at the slow interval, so a changed vault key is heard; never more.
|
||||
func asksAgainUntilItHearsTheVaultsKey(t *testing.T) {
|
||||
sealing, _ := identity.GenerateSealingKey()
|
||||
load := func() (identity.SealingKey, error) { return sealing, nil }
|
||||
pem := func(c string) string { return "-----BEGIN PUBLIC KEY-----\n" + c + "\n-----END PUBLIC KEY-----\n" }
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
keyless int
|
||||
every time.Duration
|
||||
change bool
|
||||
wantAtLeast int
|
||||
wantAtMost int
|
||||
}{
|
||||
{name: "a vault with no key yet is asked again until it answers one, then not", keyless: 3, wantAtLeast: 4, wantAtMost: 4},
|
||||
{name: "a held key is asked again at the slow interval, and a changed one heard", every: 20 * time.Millisecond,
|
||||
change: true, wantAtLeast: 3, wantAtMost: 1 << 20},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
vault := &keyVault{keyless: c.keyless, key: pem("AAAA")}
|
||||
h := &heardKey{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go keepNodeKeyStated(ctx, load, vault.ask, func(string) {}, time.Millisecond, 5*time.Millisecond,
|
||||
&keyHearing{heard: h.heard, held: func() bool { return h.get() != "" }, look: time.Millisecond, every: c.every})
|
||||
waitFor(t, "the vault's key heard", func() bool { return h.get() == pem("AAAA") })
|
||||
if c.change {
|
||||
vault.mu.Lock()
|
||||
vault.key = pem("BBBB")
|
||||
vault.mu.Unlock()
|
||||
waitFor(t, "the changed vault key heard", func() bool { return h.get() == pem("BBBB") })
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
n := len(vault.said())
|
||||
if n < c.wantAtLeast || n > c.wantAtMost {
|
||||
t.Errorf("node-key asked %d times, want %d to %d", n, c.wantAtLeast, c.wantAtMost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The statement a host makes names this contract (issue 786), one above the vault's box. And the vault's key its
|
||||
// answer gives is kept; an answer without one (an older vault) or with no public key changes nothing (ADR 0547 clause
|
||||
// 9).
|
||||
func TestTheNodeKeyContractIsThirteen(t *testing.T) {
|
||||
if link.NodeKeyContract != 13 {
|
||||
t.Errorf("NodeKeyContract %d", link.NodeKeyContract)
|
||||
}
|
||||
h := &heardKey{}
|
||||
h.heard(json.RawMessage(`{"recorded":true}`))
|
||||
h.heard(json.RawMessage(`{"recorded":true,"vault_key":"not a key"}`))
|
||||
if h.get() != "" {
|
||||
t.Fatalf("an answer with no key was kept: %q", h.get())
|
||||
}
|
||||
pem := "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n"
|
||||
answer, _ := json.Marshal(map[string]any{"recorded": true, "vault_key": pem})
|
||||
h.heard(answer)
|
||||
if h.get() != pem {
|
||||
t.Fatalf("key %q, want the key", h.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A declaration carries no order, so a host cannot tell an older one from a newer (novox/hq
|
||||
// 04-ISSUES/107). Its only identity was the digest of its bytes: "not the last" could be said,
|
||||
// "older" could not.
|
||||
|
||||
func keptWith(t *testing.T, sequence int64) store.Declared {
|
||||
t.Helper()
|
||||
return keptAt(t, link.Order{Sequence: sequence})
|
||||
}
|
||||
|
||||
func keptAt(t *testing.T, order link.Order) store.Declared {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"declaration": 1, "resources": []any{}, "owns_nothing": true, "sequence": order.Sequence,
|
||||
"epoch": order.Epoch,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store.Declared{Declaration: body, Signature: []byte("x")}
|
||||
}
|
||||
|
||||
func TestAnOlderDeclarationFromTheMeshIsRefused(t *testing.T) {
|
||||
err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 5})
|
||||
if err == nil {
|
||||
t.Fatal("sequence 5 was accepted over a kept 7")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "older") || !strings.Contains(err.Error(), "nothing was applied") {
|
||||
t.Fatalf("the refusal does not say what it is: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewerOrEqualDeclarationIsNot(t *testing.T) {
|
||||
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 8}); err != nil {
|
||||
t.Fatalf("sequence 8 was refused over a kept 7: %v", err)
|
||||
}
|
||||
// Equal is the same declaration again, which reconciling is for.
|
||||
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 7}); err != nil {
|
||||
t.Fatalf("the same sequence was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoOrderClaimedMeansNoOrderCompared(t *testing.T) {
|
||||
// An older controller sends none; a host that received before it understood them kept none.
|
||||
// Refusing on a guess would strand a node the moment the controller is older than the host.
|
||||
if err := refuseOlder(keptWith(t, 7), nil, link.Order{Sequence: 0}); err != nil {
|
||||
t.Fatalf("a declaration claiming no order was refused: %v", err)
|
||||
}
|
||||
if err := refuseOlder(keptWith(t, 0), nil, link.Order{Sequence: 3}); err != nil {
|
||||
t.Fatalf("a declaration was refused against a kept one that claimed no order: %v", err)
|
||||
}
|
||||
if err := refuseOlder(store.Declared{}, store.ErrNothingDeclared, link.Order{Sequence: 3}); err != nil {
|
||||
t.Fatalf("a first declaration was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **The lease epoch decides first** (novox/hq to-be 45 §6): a declaration from a controller that lost
|
||||
// its lease is refused whatever its sequence, and a new lease holder's is taken whatever its sequence.
|
||||
func TestAnOlderEpochIsRefusedAndANewerTaken(t *testing.T) {
|
||||
kept := keptAt(t, link.Order{Epoch: 57, Sequence: 3})
|
||||
err := refuseOlder(kept, nil, link.Order{Epoch: 41, Sequence: 12})
|
||||
if err == nil || !strings.Contains(err.Error(), "epoch 41") || !strings.Contains(err.Error(), "epoch 57") {
|
||||
t.Fatalf("an older epoch was not refused by name: %v", err)
|
||||
}
|
||||
if err := refuseOlder(kept, nil, link.Order{Epoch: 58, Sequence: 1}); err != nil {
|
||||
t.Fatalf("a new lease holder's first declaration was refused for its sequence: %v", err)
|
||||
}
|
||||
if err := refuseOlder(kept, nil, link.Order{Epoch: 57, Sequence: 2}); err == nil {
|
||||
t.Fatal("a lower sequence under the same epoch was accepted")
|
||||
}
|
||||
// A controller with no lease — older, or rolled back to a build from before it — is today's
|
||||
// behaviour: no epoch compared.
|
||||
if err := refuseOlder(kept, nil, link.Order{Sequence: 4}); err != nil {
|
||||
t.Fatalf("a declaration with no epoch was refused for one: %v", err)
|
||||
}
|
||||
if held, older := olderThanKept(kept, nil, link.Order{Epoch: 41, Sequence: 12}); !older ||
|
||||
held != (link.Order{Epoch: 57, Sequence: 3}) {
|
||||
t.Fatalf("olderThanKept said %v, holding %+v", older, held)
|
||||
}
|
||||
// What is kept cannot be read: no order to compare, so nothing is refused on a guess.
|
||||
if _, older := olderThanKept(store.Declared{Declaration: []byte("garbled")}, nil,
|
||||
link.Order{Epoch: 41, Sequence: 1}); older {
|
||||
t.Fatal("refused against a kept declaration nobody can read")
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
|
||||
@@ -127,83 +126,3 @@ func TestAPackageManagerThatIsNotThereRefusesWhole(t *testing.T) {
|
||||
t.Error("the refused declaration touched the machine")
|
||||
}
|
||||
}
|
||||
|
||||
// Defends the review of novox/hq issue 233: **a re-apply whose result changed is said unasked**. A machine
|
||||
// reconciles every five minutes and speaks only when its account is news; how the apply went was not part of
|
||||
// it, so a package manager that stopped answering between deliveries was never said, and one that answered
|
||||
// again never cleared. Now a failure is said once, the same failure again is not, and a clean re-apply after it
|
||||
// is — which is what clears the mesh's condition.
|
||||
func TestAReapplyWhoseResultChangedIsSaidUnasked(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conf := filepath.Join(dir, "elsewhere", "pacman.conf") // not declared: the apply cannot make it answer
|
||||
pacmanAnsweringOnce(t, conf)
|
||||
opts := options{state: filepath.Join(dir, "state.json")}
|
||||
raw := []byte(`{"declaration":1,"resources":[` +
|
||||
`{"id":"motd","type":"file","path":"` + filepath.Join(dir, "motd") + `","content":"hello\n"},` +
|
||||
`{"id":"pacman.base","type":"package","package":"base"}]}`)
|
||||
w := &adoptionWatch{}
|
||||
|
||||
// Clean once, as it was before: base installed while pacman answered.
|
||||
if err := os.MkdirAll(filepath.Dir(conf), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(conf, []byte("[options]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clean := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
|
||||
if faulty(clean) {
|
||||
t.Fatalf("the first apply was not clean: %+v", clean)
|
||||
}
|
||||
w.said(clean)
|
||||
if w.news(applyAndKeep(context.Background(), opts, raw, nil, nil, nil)) {
|
||||
t.Error("a clean re-apply after a clean one was said, so every machine speaks every five minutes")
|
||||
}
|
||||
|
||||
// The configuration goes: the re-apply fails, and that is news once.
|
||||
if err := os.Remove(conf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failed := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
|
||||
if !faulty(failed) {
|
||||
t.Fatalf("the re-apply did not fail: %+v", failed)
|
||||
}
|
||||
if !w.news(failed) {
|
||||
t.Fatal("a re-apply that failed after a clean one was not said, so the mesh never hears the machine fails")
|
||||
}
|
||||
w.said(failed)
|
||||
if w.news(applyAndKeep(context.Background(), opts, raw, nil, nil, nil)) {
|
||||
t.Error("the same failure was said again on the next re-apply")
|
||||
}
|
||||
|
||||
// It comes back: the clean re-apply is news, and is what clears the mesh's condition.
|
||||
if err := os.WriteFile(conf, []byte("[options]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := applyAndKeep(context.Background(), opts, raw, nil, nil, nil)
|
||||
if faulty(again) {
|
||||
t.Fatalf("the re-apply did not come back clean: %+v", again)
|
||||
}
|
||||
if !w.news(again) {
|
||||
t.Fatal("a clean re-apply after a failed one was not said, so the mesh's condition never clears")
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal is news too, once, and a reconcile with nothing to say after a clean one stays silent.
|
||||
func TestARefusalIsSaidOnceAndNothingIsNotSaid(t *testing.T) {
|
||||
w := &adoptionWatch{}
|
||||
w.said(link.Report{})
|
||||
if w.news(link.Report{}) {
|
||||
t.Error("nothing was said again")
|
||||
}
|
||||
refused := link.Report{Refused: "pacman does not answer here"}
|
||||
if !w.news(refused) {
|
||||
t.Fatal("a refusal was not said")
|
||||
}
|
||||
w.said(refused)
|
||||
if w.news(link.Report{Refused: "pacman does not answer here, at another time"}) {
|
||||
t.Error("the same refusal in other words was said again")
|
||||
}
|
||||
if !w.news(link.Report{}) {
|
||||
t.Error("a clean apply after a refusal was not said")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
)
|
||||
|
||||
// provisionSecrets fills a provision's value into a file the provisioner's client hands the node-engine to write
|
||||
// (novox/hq ADR 0570 clause 6): the client fills the file but for each `${secret:<provision>}`, and the node-engine
|
||||
// fills those at the write, so the client never holds the value. A whole `secrets` file of a handed consumer is the
|
||||
// placeholder alone.
|
||||
//
|
||||
// A placeholder names the credential as the module calls it, `${secret:<name>}` (catalogue.SecretLocal, ADR 0094): the
|
||||
// local name where the module keeps several values of one provision, else the provision. For each, it finds the one
|
||||
// provision key `<node>.<consumer>.<provision>[.<local>]` of the seat mesh-provisioner's state `provisions` (clause 1)
|
||||
// going by that name, which must be `provided` and carry the vault's reference; asks the vault for that pair
|
||||
// credential sealed to this machine's key (the verb `own`, ADR 0349 §11 with the node-engine standing for the client,
|
||||
// ADR 0550 §5); and opens it with this machine's sealing key. Anything short of that refuses the write, naming the
|
||||
// provision and why: a file written with a placeholder left in it, or with no value, is a module that starts and cannot
|
||||
// connect.
|
||||
type provisionSecrets struct {
|
||||
node string
|
||||
// list is every key of mesh-provisioner's `provisions` under prefix (`<node>.<consumer>.`), by key: what this
|
||||
// engine may read, its own node's (`<node>.>`).
|
||||
list func(ctx context.Context, prefix string) (map[string][]byte, error)
|
||||
// own asks the vault's verb `own` in this engine's name.
|
||||
own func(ctx context.Context, args map[string]any) (json.RawMessage, error)
|
||||
// open opens a value sealed to this machine.
|
||||
open func(sealed string) ([]byte, error)
|
||||
}
|
||||
|
||||
// secretPlaceholder is `${secret:<name>}`.
|
||||
var secretPlaceholder = regexp.MustCompile(`\$\{secret:([A-Za-z0-9_-]+)\}`)
|
||||
|
||||
// fill is content with each provision placeholder of module's filled, and whether any was.
|
||||
func (p provisionSecrets) fill(ctx context.Context, module string, content []byte) ([]byte, bool, error) {
|
||||
found := secretPlaceholder.FindAllSubmatch(content, -1)
|
||||
if len(found) == 0 {
|
||||
return content, false, nil
|
||||
}
|
||||
if module == "" {
|
||||
return nil, false, fmt.Errorf("the file names %d provision secret(s) and belongs to no module, so whose they are is not known", len(found))
|
||||
}
|
||||
if p.list == nil || p.own == nil || p.open == nil {
|
||||
return nil, false, fmt.Errorf("%s's provisions cannot be read on this machine: the node-engine is not linked to the mesh", module)
|
||||
}
|
||||
prefix := p.node + "." + module + "."
|
||||
keys, err := p.list(ctx, prefix)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("%s's provisions cannot be read: %w", module, err)
|
||||
}
|
||||
out := string(content)
|
||||
done := map[string]bool{}
|
||||
for _, f := range found {
|
||||
whole, name := string(f[0]), string(f[1])
|
||||
if done[whole] {
|
||||
continue
|
||||
}
|
||||
done[whole] = true
|
||||
pr, key, err := byName(keys, name)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("%s's secret %q: %w", module, name, err)
|
||||
}
|
||||
value, err := p.value(ctx, key, pr)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
out = strings.ReplaceAll(out, whole, string(value))
|
||||
}
|
||||
return []byte(out), true, nil
|
||||
}
|
||||
|
||||
// byName is the one provision of keys the module calls name (catalogue.SecretLocal), and its key.
|
||||
func byName(keys map[string][]byte, name string) (provisions.Provision, string, error) {
|
||||
var hit []string
|
||||
var got provisions.Provision
|
||||
for key, raw := range keys {
|
||||
pr, err := provisions.Decode(raw)
|
||||
if err != nil {
|
||||
return provisions.Provision{}, "", fmt.Errorf("the provision %s: %w", key, err)
|
||||
}
|
||||
if catalogue.SecretLocal(pr.Provision, pr.Local) == name {
|
||||
hit, got = append(hit, key), pr
|
||||
}
|
||||
}
|
||||
switch len(hit) {
|
||||
case 0:
|
||||
return provisions.Provision{}, "", fmt.Errorf("no provision of %s goes by that name: nothing asks for it", buckets.ProvisionsRead)
|
||||
case 1:
|
||||
return got, hit[0], nil
|
||||
}
|
||||
sort.Strings(hit)
|
||||
return provisions.Provision{}, "", fmt.Errorf("the provisions %s all go by that name", strings.Join(hit, ", "))
|
||||
}
|
||||
|
||||
// value is one provision's value on this node, opened.
|
||||
func (p provisionSecrets) value(ctx context.Context, key string, pr provisions.Provision) ([]byte, error) {
|
||||
if pr.State != provisions.Provided || pr.Reference == nil {
|
||||
why := pr.State
|
||||
if pr.Why != "" {
|
||||
why += ": " + pr.Why
|
||||
}
|
||||
if pr.State == provisions.Provided {
|
||||
why = "provided with no reference to the vault's value"
|
||||
}
|
||||
return nil, fmt.Errorf("the provision %s is %s, so its value is not written yet", key, why)
|
||||
}
|
||||
// The reference names a credential of this provision, as the vault names it (provisions.VaultName).
|
||||
ours := false
|
||||
for _, form := range []string{provisions.FormPair, provisions.FormShared, provisions.FormIssued} {
|
||||
ours = ours || provisions.VaultName(form, pr) == pr.Reference.ID
|
||||
}
|
||||
if !ours {
|
||||
return nil, fmt.Errorf("the provision %s names the vault's %s, which is no credential of it", key, pr.Reference.ID)
|
||||
}
|
||||
module, provision, local := pr.Consumer.Module, pr.Provision, pr.Local
|
||||
type asked struct {
|
||||
Role string `json:"role"`
|
||||
Provision string `json:"provision"`
|
||||
Consumer string `json:"consumer"`
|
||||
ConsumerModule string `json:"consumer_module"`
|
||||
Provider string `json:"provider"`
|
||||
Local string `json:"local,omitempty"`
|
||||
}
|
||||
ask := asked{Role: "consumer", Provision: provision, Consumer: p.node, ConsumerModule: module, Provider: pr.Provider.Node, Local: local}
|
||||
answer, err := p.own(ctx, map[string]any{"credentials": []asked{ask}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the vault's value of %s: %w", key, err)
|
||||
}
|
||||
var a struct {
|
||||
Node string `json:"node"`
|
||||
Credentials []struct {
|
||||
asked
|
||||
Name string `json:"name"`
|
||||
Generation int `json:"generation"`
|
||||
Sealed json.RawMessage `json:"sealed"`
|
||||
} `json:"credentials"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &a); err != nil {
|
||||
return nil, fmt.Errorf("the vault's answer for %s is not one: %w", key, err)
|
||||
}
|
||||
if a.Node != p.node {
|
||||
return nil, fmt.Errorf("the vault answered %s's credentials for %s on %s", a.Node, key, p.node)
|
||||
}
|
||||
for _, c := range a.Credentials {
|
||||
if c.asked != ask {
|
||||
continue
|
||||
}
|
||||
// The value the provision names, by the vault's credential name and its generation (clause 6): another is refused.
|
||||
if c.Name != pr.Reference.ID || c.Generation != pr.Reference.Generation {
|
||||
return nil, fmt.Errorf("the vault answered %s generation %d for %s, and the provision names %s generation %d: "+
|
||||
"nothing is written until they agree", c.Name, c.Generation, key, pr.Reference.ID, pr.Reference.Generation)
|
||||
}
|
||||
value, err := p.open(string(c.Sealed))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the vault's value of %s does not open on this machine: %w", key, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
return nil, fmt.Errorf("the vault answered no value of %s for this machine", key)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
)
|
||||
|
||||
// pullsOf is the images an apply could not find or pull, each with the seat it came through and that seat's holder as the
|
||||
// declaration names them beside the container (design 51 *distribution's first delivery*, ENG-8, ENG-10). An image the
|
||||
// declaration names no holder for (a public registry's) carries none: its pull is not tried again on a store settling.
|
||||
func pullsOf(d *declaration.Declaration, err error) []agent.PullFailure {
|
||||
var out []agent.PullFailure
|
||||
for _, p := range apply.PullErrors(err) {
|
||||
f := agent.PullFailure{Image: p.Image, Started: p.Started}
|
||||
for _, r := range d.Resources {
|
||||
if c, ok := r.(*declaration.Container); ok && c.Image == p.Image && c.ImageHolder != "" {
|
||||
f.Seat, f.Holder = c.ImageSeat, c.ImageHolder
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// The record check reads the engine's applied state: a composition one of whose resources has no record there is not
|
||||
// recorded (issue 1945).
|
||||
func TestACompositionIsRecordedOnlyWhenEveryResourceIs(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
var s store.State
|
||||
s.Record(store.Applied{ID: "ca-trust.trust", Type: "file", Target: "/etc/ca"})
|
||||
if err := store.Save(path, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := engcomp.Composed{Node: "ace", Module: "ca-trust", Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"ca-trust.trust","type":"file","module":"ca-trust","path":"/etc/ca","mode":"0644","content":"x"}`)}}
|
||||
recorded := recordedIn(path, func(string) {})
|
||||
if !recorded(context.Background(), c) {
|
||||
t.Fatal("a composition whose every resource has a record was not recorded")
|
||||
}
|
||||
c.Resources = append(c.Resources, json.RawMessage(
|
||||
`{"id":"ca-trust.more","type":"file","module":"ca-trust","path":"/etc/ca2","mode":"0644","content":"y"}`))
|
||||
if recorded(context.Background(), c) {
|
||||
t.Fatal("a composition with a resource the state holds no record of was recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// The modules applied again are those with a missing resource, each once; one down but there is not among them.
|
||||
func TestTheModulesAppliedAgainAreThoseWithAMissingResource(t *testing.T) {
|
||||
t.Run("1957: the machine shows a resource not active", testTheMachineShowsAResourceNotActive)
|
||||
st := liveness.Statement{Resources: []liveness.State{
|
||||
{Resource: liveness.Resource{Module: "matrix", ID: "matrix.homeserver"}, State: liveness.Unhealthy, Missing: true},
|
||||
{Resource: liveness.Resource{Module: "matrix", ID: "matrix.db"}, State: liveness.Unhealthy, Missing: true},
|
||||
{Resource: liveness.Resource{Module: "ca-trust", ID: "ca-trust.trust"}, State: liveness.Unhealthy, Missing: true},
|
||||
{Resource: liveness.Resource{Module: "web", ID: "web.server"}, State: liveness.Unhealthy},
|
||||
}}
|
||||
if got := missingModules(st); len(got) != 2 || got[0] != "ca-trust" || got[1] != "matrix" {
|
||||
t.Fatalf("applied again: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A module is applied again only for a resource newly missing: one still missing at the next change of the statement is
|
||||
// not acted on again, so a resource that cannot be made is not applied (and backed up) at every unrelated change. One
|
||||
// that comes back and goes again is newly missing again (the review of issue 1945).
|
||||
func TestOnlyANewlyMissingResourceAppliesItsModuleAgain(t *testing.T) {
|
||||
t.Run("1957: a resource newly down is an edge", testOnlyAResourceNewlyDownIsAnEdge)
|
||||
missing := func(ids ...string) liveness.Statement {
|
||||
var st liveness.Statement
|
||||
for _, id := range ids {
|
||||
st.Resources = append(st.Resources, liveness.State{Resource: liveness.Resource{Module: "matrix", ID: id},
|
||||
State: liveness.Unhealthy, Missing: true})
|
||||
}
|
||||
return st
|
||||
}
|
||||
var last map[string]bool
|
||||
var got []string
|
||||
got, last = newlyMissing(last, missing("matrix.homeserver"))
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("first missing: %v", got)
|
||||
}
|
||||
got, last = newlyMissing(last, missing("matrix.homeserver"))
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("still missing was acted on again: %v", got)
|
||||
}
|
||||
got, last = newlyMissing(last, missing())
|
||||
got, last = newlyMissing(last, missing("matrix.homeserver"))
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("missing again after it came back: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A file the provisioner client fills is left to it by the apply and has no record until the client writes it: it does
|
||||
// not make its composition unrecorded (the review of issue 1945).
|
||||
func TestAFilledFileDoesNotMakeItsCompositionUnrecorded(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
var s store.State
|
||||
s.Record(store.Applied{ID: "ca-trust.trust", Type: "file", Target: "/etc/ca"})
|
||||
if err := store.Save(path, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := engcomp.Composed{Node: "ace", Module: "ca-trust", Filled: []string{"/etc/ca-filled"}, Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"ca-trust.trust","type":"file","module":"ca-trust","path":"/etc/ca","mode":"0644","content":"x"}`),
|
||||
json.RawMessage(`{"id":"ca-trust.filled","type":"file","module":"ca-trust","path":"/etc/ca-filled","mode":"0644","owner-at-runtime":"owned"}`)}}
|
||||
if !recordedIn(path, func(string) {})(context.Background(), c) {
|
||||
t.Fatal("a filled file the client has not written made its composition unrecorded")
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0105: `overlay take` moves this node's overlay key onto the found tunnel's and
|
||||
// nothing else — identity, sealing and serving keys stay as they were — and tells the mesh with a
|
||||
// proof signed by the identity key, over the previous key, the new one and the tunnel.
|
||||
|
||||
func anEnrolledNode(t *testing.T) identity.Identity {
|
||||
t.Helper()
|
||||
mine, err := identity.Generate("anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine.Overlay, err = identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine.Membership = identity.Membership{Broker: "198.51.100.1:5671", Fingerprint: "sha256:aa",
|
||||
Signer: make([]byte, ed25519.PublicKeySize), Password: "p"}
|
||||
return mine
|
||||
}
|
||||
|
||||
func aFoundTunnel(t *testing.T) tunnel.Found {
|
||||
t.Helper()
|
||||
private, err := identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := tunnel.Parse([]byte("[Interface]\nPrivateKey = " + private.Private + "\nListenPort = 51900\n" +
|
||||
"Address = 192.0.2.1/24\n[Peer]\nPublicKey = PEER-A=\nAllowedIPs = 192.0.2.2/32\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found.Interface, found.Unit, found.Config = "wg0", "wg-quick@wg0", "/etc/wireguard/wg0.conf"
|
||||
return found
|
||||
}
|
||||
|
||||
func TestTakingATunnelMovesOnlyTheOverlayKeyAndSignsForIt(t *testing.T) {
|
||||
mine := anEnrolledNode(t)
|
||||
found := aFoundTunnel(t)
|
||||
before := mine.Overlay.Public
|
||||
|
||||
taken, rekey, err := rekeyOnto(mine, found)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken.Overlay.Public != found.PublicKey || taken.Overlay.Private != found.PrivateKey() {
|
||||
t.Fatal("the overlay key is not the tunnel's")
|
||||
}
|
||||
if string(taken.Public) != string(mine.Public) || string(taken.Private) != string(mine.Private) ||
|
||||
taken.Node != mine.Node || taken.Membership.Password != mine.Membership.Password ||
|
||||
taken.Membership.Broker != mine.Membership.Broker {
|
||||
t.Fatal("something other than the overlay key moved")
|
||||
}
|
||||
if taken.OverlayBefore != before {
|
||||
t.Errorf("the key before the take was not kept: %q", taken.OverlayBefore)
|
||||
}
|
||||
if rekey.Previous != before || rekey.OverlayKey != found.PublicKey || rekey.Tunnel == nil ||
|
||||
rekey.Tunnel.PublicKey != found.PublicKey || len(rekey.Tunnel.Peers) != 1 {
|
||||
t.Fatalf("the rekey does not say what moved: %+v", rekey)
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(mine.Public),
|
||||
link.RekeyProof("anchor", before, found.PublicKey, rekey.Tunnel), rekey.Proof) {
|
||||
t.Fatal("the rekey is not signed by this node's identity key over what it says")
|
||||
}
|
||||
if ed25519.Verify(ed25519.PublicKey(mine.Public),
|
||||
link.RekeyProof("laptop", before, found.PublicKey, rekey.Tunnel), rekey.Proof) {
|
||||
t.Fatal("the proof is not bound to the node")
|
||||
}
|
||||
for _, said := range []string{rekey.Previous, rekey.OverlayKey, rekey.Tunnel.Interface} {
|
||||
if strings.Contains(said, found.PrivateKey()) {
|
||||
t.Fatal("the private key travels")
|
||||
}
|
||||
}
|
||||
|
||||
// Run again after the take — the mesh not yet told, or told and refused — the previous key it
|
||||
// names is still the one before the take, so the mesh can tell a repeat from a replay.
|
||||
again, second, err := rekeyOnto(taken, found)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Previous != before || again.OverlayBefore != before || again.Overlay.Public != found.PublicKey {
|
||||
t.Fatalf("a take run again does not name the key before the first: %+v", second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// runsAsWritten is what the node runs as the composer reads it (selfcompose.Env.Running), with every file given by digest
|
||||
// named as this node last wrote it, where the two differ (issue 924, novox 2026-10-29 01:16). What runs of a module the
|
||||
// agent has not applied since its start can come from the kept declaration, older than the compositions applied since:
|
||||
// its export (mesh-vault.kept) then named a digest the controller's sweep had removed, while the file on disk was the
|
||||
// newer export the node wrote. A node composing its own carries such a file as it runs (mesh-sdk frombus keptOf), so it
|
||||
// names what is on disk: **only when the node's record says it wrote exactly that content there** (store Applied.Wrote,
|
||||
// Target), never a file someone else changed. A file that cannot be read, or that the record does not vouch for, is
|
||||
// left as the composition names it.
|
||||
func runsAsWritten(running []engcomp.Composed, statePath string) []engcomp.Composed {
|
||||
if len(running) == 0 || statePath == "" {
|
||||
return running
|
||||
}
|
||||
known, err := store.Load(statePath)
|
||||
if err != nil {
|
||||
return running
|
||||
}
|
||||
out := make([]engcomp.Composed, len(running))
|
||||
for i, c := range running {
|
||||
out[i] = c
|
||||
var changed []json.RawMessage
|
||||
for j, raw := range c.Resources {
|
||||
if fixed, ok := asWritten(raw, known); ok {
|
||||
if changed == nil {
|
||||
changed = append([]json.RawMessage(nil), c.Resources...)
|
||||
}
|
||||
changed[j] = fixed
|
||||
}
|
||||
}
|
||||
if changed != nil {
|
||||
out[i].Resources = changed
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// asWritten is the file resource raw named by the digest and size of what this node last wrote at its path, when raw is a
|
||||
// file given by another digest and the file there is what the record says the node wrote.
|
||||
func asWritten(raw json.RawMessage, known store.State) (json.RawMessage, bool) {
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &fields) != nil {
|
||||
return nil, false
|
||||
}
|
||||
var kind, id, path, digest string
|
||||
_ = json.Unmarshal(fields["type"], &kind)
|
||||
_ = json.Unmarshal(fields["id"], &id)
|
||||
_ = json.Unmarshal(fields["path"], &path)
|
||||
_ = json.Unmarshal(fields["digest"], &digest)
|
||||
if kind != string(declaration.TypeFile) || digest == "" || path == "" {
|
||||
return nil, false
|
||||
}
|
||||
rec, ok := known.Find(id)
|
||||
if !ok || rec.Wrote == "" || rec.Target != path {
|
||||
return nil, false
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
if hex.EncodeToString(sum[:]) != rec.Wrote {
|
||||
return nil, false // changed since the node wrote it: not the node's to vouch for
|
||||
}
|
||||
if now := declaration.DigestOf(body); now == digest {
|
||||
return nil, false
|
||||
} else {
|
||||
fields["digest"], _ = json.Marshal(now)
|
||||
}
|
||||
fields["size"], _ = json.Marshal(len(body))
|
||||
fixed, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return fixed, true
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
conditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/seatwait"
|
||||
)
|
||||
|
||||
// The node-engine reads the seat list as every part does (novox/hq ADR 0635 §1, ADR 0636): through the seat mesh-catalog,
|
||||
// from the bootstrapped definitions until it has been read, acting on nothing new meanwhile (the composer's gate), and
|
||||
// following the seat's events. What it holds is given to catalogue at every change, which compiles no seat list.
|
||||
|
||||
// seatListNow is this run's reader, what the node-engine's verbs seat-list and seat-list-again answer from.
|
||||
var seatListNow atomic.Pointer[seatListState]
|
||||
|
||||
// seatListRead answers the verb seat-list: what the engine holds of the list and what it waits for (ADR 0636 §7).
|
||||
func seatListRead() (any, error) {
|
||||
st := seatListNow.Load()
|
||||
if st == nil {
|
||||
return nil, errors.New("this engine's node agent has not started, so it holds no seat list yet")
|
||||
}
|
||||
status := st.reader.Status()
|
||||
return map[string]any{"status": status, "health": status.Health()}, nil
|
||||
}
|
||||
|
||||
// seatListAgain answers the verb seat-list-again: the list read now from the seat, and what is held afterwards.
|
||||
func seatListAgain() (any, error) {
|
||||
st := seatListNow.Load()
|
||||
if st == nil {
|
||||
return nil, errors.New("this engine's node agent has not started, so it holds no seat list yet")
|
||||
}
|
||||
err := st.reader.Reload()
|
||||
st.waiter.Changed() // a read that changed nothing held still ends the wait
|
||||
out, _ := seatListRead()
|
||||
if err != nil {
|
||||
out.(map[string]any)["error"] = err.Error()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seatListState is one run's reader, the finding that it waits, and the channel on which the composer hears a change.
|
||||
type seatListState struct {
|
||||
reader *seatlist.Reader
|
||||
waiter *seatwait.Waiter
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
// sessionBus is the reader's bus over the link: a verb of the seat is asked in the engine's own name, an event heard on
|
||||
// the seat's event subject for as long as ctx lives.
|
||||
type sessionBus struct {
|
||||
ctx context.Context
|
||||
session *link.Session
|
||||
}
|
||||
|
||||
func (b sessionBus) Ask(key string, body any) (json.RawMessage, error) {
|
||||
seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".")
|
||||
return b.session.AskMeshSeat(b.ctx, seat, verb, body)
|
||||
}
|
||||
|
||||
func (b sessionBus) Subscribe(pattern string, handle func(string, json.RawMessage) error) error {
|
||||
seat, event, _ := strings.Cut(pattern, ".")
|
||||
heard, stop, err := b.session.Hear(seatdef.EventSubject(seat, event))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
defer stop()
|
||||
for {
|
||||
select {
|
||||
case <-b.ctx.Done():
|
||||
return
|
||||
case body, ok := <-heard:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = handle(pattern, body)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// seatWaitClock is the clock of the node's bound; the tests give their own.
|
||||
var seatWaitClock seatwait.Clock = realClock{}
|
||||
|
||||
type realClock struct{}
|
||||
|
||||
func (realClock) Now() time.Time { return time.Now() }
|
||||
func (realClock) AfterFunc(d time.Duration, f func()) seatwait.Timer {
|
||||
return time.AfterFunc(d, f)
|
||||
}
|
||||
|
||||
// findingsOfTheNode writes the node's findings in its state `findings`, in the layout of the seat mesh-conditions.
|
||||
type findingsOfTheNode struct{ session *link.Session }
|
||||
|
||||
func (w findingsOfTheNode) encode(f views.Finding) (string, []byte, error) {
|
||||
key := conditions.Key(conditions.ScopeMachine, f.ID, f.Kind)
|
||||
v := condseat.FindingsByKeyValue{V: 1, Key: key, Kind: f.Kind, Observer: f.Observer, Severity: conditions.Warning,
|
||||
Subject: condseat.Subject{Scope: conditions.ScopeMachine, ID: f.ID, Machine: f.ID}, Summary: f.Summary,
|
||||
Headline: f.Headline, Explanation: f.Explanation, Source: "seat-list-unread", At: f.At.UTC()}
|
||||
if err := conditions.CheckFinding(v); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
return key, raw, err
|
||||
}
|
||||
|
||||
func (w findingsOfTheNode) Put(f views.Finding) error {
|
||||
key, raw, err := w.encode(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.session.PutFinding(key, raw)
|
||||
}
|
||||
|
||||
func (w findingsOfTheNode) Delete(f views.Finding) error {
|
||||
key, _, err := w.encode(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.session.DeleteFinding(key)
|
||||
}
|
||||
|
||||
// startSeatList starts this run's reader: its bootstrapped seats are given to catalogue at once, and every change of what
|
||||
// it holds again, saying so to the composer and to the waiter that writes the five-minute finding.
|
||||
func startSeatList(ctx context.Context, session *link.Session, node string, say func(string)) (*seatListState, error) {
|
||||
st := &seatListState{changed: make(chan struct{}, 1)}
|
||||
// Start returns at once and reads the seat on a goroutine, which may announce a change before the waiter exists.
|
||||
var announce atomic.Pointer[func()]
|
||||
reader, err := seatlist.Start(seatlist.Config{Bus: sessionBus{ctx, session}, Bootstrap: seatlist.Bootstrapped(),
|
||||
OnChange: func(seats []catalogue.Seat) {
|
||||
catalogue.UseSeats(seats)
|
||||
if f := announce.Load(); f != nil { // the first read may end before the waiter exists
|
||||
(*f)()
|
||||
}
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the node-engine cannot follow the seat list: %w", err)
|
||||
}
|
||||
st.reader = reader
|
||||
st.waiter = &seatwait.Waiter{Node: node, Observer: "node-engine", Reader: reader, Findings: findingsOfTheNode{session},
|
||||
Clock: seatWaitClock, Say: say}
|
||||
told := func() {
|
||||
st.waiter.Changed()
|
||||
select {
|
||||
case st.changed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
announce.Store(&told)
|
||||
catalogue.UseSeats(reader.Seats())
|
||||
told()
|
||||
// The first read ends whether the seat answered or not, and a read that held what the reader held already says no
|
||||
// change: its end is told to the waiter and the composer, so a status that moved from reading to heard starts or ends
|
||||
// the bound (ADR 0636 §1). Start's return held nobody's start.
|
||||
go func() {
|
||||
select {
|
||||
case <-reader.Ready():
|
||||
catalogue.UseSeats(reader.Seats())
|
||||
told()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
seatListNow.Store(st)
|
||||
return st, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/selfcompose"
|
||||
)
|
||||
|
||||
// ADR 0636 §1: when the node's reader of the seat list could not start, the composer is gated by a reader that never
|
||||
// allows, where a missing reader would let the node act on everything; the shadow, which applies nothing, stays ungated.
|
||||
func TestAReaderThatCouldNotStartHoldsTheComposer(t *testing.T) {
|
||||
failed := composerEnv(nil, "n1", func(string) {}, composerHooks{seatListFailed: true})
|
||||
if failed.SeatList == nil || failed.SeatList.MayAct() || failed.SeatList.MayAct("node-engine") {
|
||||
t.Fatalf("the composer of a node whose reader did not start is gated by %v, want a gate that allows nothing", failed.SeatList)
|
||||
}
|
||||
if _, ok := failed.SeatList.(selfcompose.Unread); !ok {
|
||||
t.Errorf("gate is %T", failed.SeatList)
|
||||
}
|
||||
if got := composerEnv(nil, "n1", func(string) {}, composerHooks{}).SeatList; got != nil {
|
||||
t.Errorf("a composer with no reader asked for gates by %v, want none (the shadow)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
// settledEvent is where the status view says a module has settled on a node: its have reached its want, running and healthy
|
||||
// (design 47; the view's `settled`, said once for a module on a node at one composition). The engine hears it for every module
|
||||
// and tells its agent, which tries again, once per settled, a failed apply that waits on that module as the holder of the store
|
||||
// its image comes from (design 51 *distribution's first delivery*, issue 1966 ENG-11). The engine's manifest consumes it (`mesh-delivery-view.settled`),
|
||||
// and that line alone grants the engine's account the subject (TestTheSettledEventIsTheManifestsConsume); a refused subscription is said, and made again when the next link opens.
|
||||
const settledEvent = "mesh.mod.mesh-delivery-view.event.settled"
|
||||
|
||||
// settledBody is what of the event the engine reads: the module that settled, on whichever node.
|
||||
type settledBody struct {
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// settledTold is who is told a holder has settled.
|
||||
type settledTold interface{ Settled(holder string) }
|
||||
|
||||
// followSettled hears the status view's `settled` on every link, never on a timer, until ctx ends. now names the agent running
|
||||
// now (nil while the node agent begins again): a settled heard while there is none is not lost for it, since a new agent applies
|
||||
// again every have that failed at its start.
|
||||
func followSettled(ctx context.Context, session *link.Session, now func() settledTold, say link.Announce) {
|
||||
said := ""
|
||||
for ctx.Err() == nil {
|
||||
linked, cancel, err := session.WhenLinked(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
events, stop, herr := session.Hear(settledEvent)
|
||||
if herr != nil {
|
||||
if line := fmt.Sprintf("a store settling is not heard yet (%s: %v): a failed pull waits for the next link, or for compose", settledEvent, herr); line != said {
|
||||
said = line
|
||||
say(line)
|
||||
}
|
||||
} else {
|
||||
said = ""
|
||||
listen(linked, events, now, say)
|
||||
stop()
|
||||
}
|
||||
<-linked.Done()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// listen tells the agent each settled it hears, until the link ends.
|
||||
func listen(linked context.Context, events <-chan []byte, now func() settledTold, say link.Announce) {
|
||||
for {
|
||||
select {
|
||||
case <-linked.Done():
|
||||
return
|
||||
case raw, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var b settledBody
|
||||
if json.Unmarshal(raw, &b) != nil || b.Module == "" {
|
||||
continue
|
||||
}
|
||||
if told := now(); told != nil {
|
||||
told.Settled(b.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The subject the engine hears for a store settling is the event its manifest consumes: the grants part grants an account
|
||||
// what its manifest consumes and nothing named in code (novox/hq ADR 0625 §4).
|
||||
func TestTheSettledEventIsTheManifestsConsume(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Consumes []string `json:"consumes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emitter, event, _ := strings.Cut(strings.TrimPrefix(settledEvent, "mesh.mod."), ".event.")
|
||||
if !slices.Contains(m.Consumes, emitter+"."+event) {
|
||||
t.Errorf("the engine hears %s, its manifest consumes %v", settledEvent, m.Consumes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// stopBound is how long a stopping engine waits for the apply in flight to end and save what it did: under the service
|
||||
// manager's own stop bound (90 s by default, then every process of the unit is killed), so the wait ends with a line in
|
||||
// the journal rather than with a kill.
|
||||
const stopBound = 60 * time.Second
|
||||
|
||||
// stopBetweenApplies is how this engine ends, whichever way (a stop of the service, standing aside for a successor, a
|
||||
// move to another bus): it waits, up to bound, for the apply in flight to end, then lets no save start and waits for
|
||||
// the one writing (novox/hq issue 1585). An apply runs detached from the engine's own stop (applyContext), so without
|
||||
// this the process ended under it: what it unpacked was never recorded, and a save cut between its write and its
|
||||
// rename left a temporary file and the state before it. A stopping engine never gives back what it takes (the process
|
||||
// ends); the release it returns is for a test, and gives back the apply only when this took it.
|
||||
func stopBetweenApplies(say func(string), bound time.Duration) (release func()) {
|
||||
took := true
|
||||
if !applying.TryLock() {
|
||||
say(fmt.Sprintf("stopping: waiting up to %s for the apply in flight to end and save what it did", bound))
|
||||
got := make(chan struct{})
|
||||
go func() {
|
||||
applying.Lock()
|
||||
close(got)
|
||||
}()
|
||||
select {
|
||||
case <-got:
|
||||
say("stopping: the apply in flight ended and its state is saved")
|
||||
case <-time.After(bound):
|
||||
took = false
|
||||
say(fmt.Sprintf("stopping while an apply still runs after %s: what it did since its last save is not "+
|
||||
"recorded, and the next start applies its module again", bound))
|
||||
}
|
||||
}
|
||||
saves := store.Quiesce()
|
||||
return func() {
|
||||
saves()
|
||||
if took {
|
||||
applying.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeLeftoverSaves removes, at the engine's start, the temporary files of saves of the node's state that never
|
||||
// reached their rename, and says how many (issue 1585: about 260 on novox, ~80 MB).
|
||||
func removeLeftoverSaves(statePath string, say func(string)) {
|
||||
removed, err := store.RemoveLeftovers(statePath)
|
||||
if len(removed) > 0 {
|
||||
shown := removed
|
||||
if len(shown) > 5 {
|
||||
shown = append(append([]string(nil), shown[:5]...), "…")
|
||||
}
|
||||
say(fmt.Sprintf("removed %d leftover save(s) of this node's state, cut off before their rename by an earlier "+
|
||||
"stop: %s", len(removed), strings.Join(shown, ", ")))
|
||||
}
|
||||
if err != nil {
|
||||
say(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// exitBetweenApplies ends the process cleanly once no apply runs and no save writes: an exit that is not runLink's
|
||||
// return (a move to another bus). A variable, so a test sees it called rather than the test process ending.
|
||||
var exitBetweenApplies = func(say func(string)) {
|
||||
_ = stopBetweenApplies(say, stopBound)
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
// **What this engine trusts a mesh signature from** (novox/hq ADR 0550 §2; design 47's L3 item d; issue 1126): the
|
||||
// vault's key alone, read from the vault's state `signers`, which only the vault may write: the bus grants are its
|
||||
// anchor, as the wants' and the assignment's. The controller's key, which the node was enrolled with (its membership's
|
||||
// signer), is trusted never: the controller ended by its unassignment (the operator, 2026-11-02) and its lease is never
|
||||
// handed, so no gate waits on it. What it trusts now the node states in its record (NodeRecord.Trusts).
|
||||
type meshTrust struct {
|
||||
mu sync.Mutex
|
||||
vault ed25519.PublicKey
|
||||
authority string
|
||||
// changed is told when what it trusts changed, so the record is written again.
|
||||
changed func()
|
||||
}
|
||||
|
||||
// newMeshTrust trusts nothing until the vault's key is read.
|
||||
func newMeshTrust() *meshTrust { return &meshTrust{} }
|
||||
|
||||
// onChange names who is told when what it trusts changed.
|
||||
func (t *meshTrust) onChange(f func()) {
|
||||
t.mu.Lock()
|
||||
t.changed = f
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// Signers are the keys a mesh signature is verified against now: the vault's once read, and none before.
|
||||
func (t *meshTrust) Signers() []meshvaultclient.Signer {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
var out []meshvaultclient.Signer
|
||||
if t.vault != nil {
|
||||
out = append(out, meshvaultclient.Signer{Name: meshvaultclient.SignerVault, Key: t.vault})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Verify is the signer that signed msg as kind, or why none it trusts did.
|
||||
func (t *meshTrust) Verify(kind string, msg, sig []byte) (meshvaultclient.Signer, error) {
|
||||
context, err := meshvaultclient.SignContext(kind)
|
||||
if err != nil {
|
||||
return meshvaultclient.Signer{}, err
|
||||
}
|
||||
return meshvaultclient.VerifyBy(t.Signers(), context, msg, sig)
|
||||
}
|
||||
|
||||
// VaultAuthority is the vault's authority certificate as read from `signers`, or "".
|
||||
func (t *meshTrust) VaultAuthority() string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.authority
|
||||
}
|
||||
|
||||
// Trusts is what the record says it trusts: each signer's key and the vault's authority, by fingerprint.
|
||||
func (t *meshTrust) Trusts() []string {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
authorities := map[string]string{}
|
||||
if a := t.VaultAuthority(); a != "" {
|
||||
authorities[meshvaultclient.SignerVault] = a
|
||||
}
|
||||
return meshvaultclient.Trusts(t.Signers(), authorities)
|
||||
}
|
||||
|
||||
// change applies f under the lock and tells who follows when Trusts differs after it.
|
||||
func (t *meshTrust) change(f func()) {
|
||||
before := strings.Join(t.Trusts(), "\n")
|
||||
t.mu.Lock()
|
||||
f()
|
||||
changed := t.changed
|
||||
t.mu.Unlock()
|
||||
if strings.Join(t.Trusts(), "\n") != before && changed != nil {
|
||||
changed()
|
||||
}
|
||||
}
|
||||
|
||||
// heardSigner takes one key of the vault's `signers`: its key or its authority, refused (and said) when it does not
|
||||
// check; a deleted key is no longer trusted.
|
||||
func (t *meshTrust) heardSigner(key string, value []byte, deleted bool, say link.Announce) {
|
||||
switch key {
|
||||
case meshvault.SignersByKeyKey():
|
||||
var pub ed25519.PublicKey
|
||||
if !deleted && len(value) > 0 {
|
||||
k, err := meshvaultclient.DecodeSignerKey(value)
|
||||
if err == nil {
|
||||
pub, err = meshvaultclient.SignerPublicKey(k)
|
||||
}
|
||||
if err != nil {
|
||||
say("the vault's signing key in " + buckets.SignersRead + " is not trusted: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
t.change(func() { t.vault = pub })
|
||||
case meshvault.SignersByAuthorityKey():
|
||||
pem := ""
|
||||
if !deleted && len(value) > 0 {
|
||||
a, err := meshvaultclient.DecodeSignerAuthority(value)
|
||||
if err != nil {
|
||||
say("the vault's authority in " + buckets.SignersRead + " is not trusted: " + err.Error())
|
||||
return
|
||||
}
|
||||
pem = a.Certificate
|
||||
}
|
||||
t.change(func() { t.authority = pem })
|
||||
}
|
||||
}
|
||||
|
||||
// trustFollowAgain bounds the wait before a watch that could not be made is tried again on the same link: the vault may
|
||||
// not serve `sign` yet, and until it does this node is not granted the reads.
|
||||
var trustFollowAgain = 2 * time.Minute
|
||||
|
||||
// followTrust follows the vault's `signers` on every link, as watches (never a poll), for as
|
||||
// long as ctx lasts. A watch that cannot be made is said once per reason and tried again.
|
||||
func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, say link.Announce) {
|
||||
said := ""
|
||||
sayOnce := func(line string) {
|
||||
if line != said {
|
||||
said = line
|
||||
if line != "" {
|
||||
say(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
for ctx.Err() == nil {
|
||||
linked, cancel, err := session.WhenLinked(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
watching, stop := context.WithCancel(linked)
|
||||
signers, serr := session.KVWatch(watching, buckets.Signers, ">")
|
||||
if serr != nil {
|
||||
stop()
|
||||
sayOnce(fmt.Sprintf("what this node trusts a mesh signature from is not followed yet (the vault's signers: %v): "+
|
||||
"it trusts what it last read, and looks again in %s", serr, trustFollowAgain))
|
||||
} else {
|
||||
sayOnce("")
|
||||
for u := range signers {
|
||||
if !u.Caught {
|
||||
trust.heardSigner(u.Key, u.Value, u.Deleted, say)
|
||||
}
|
||||
}
|
||||
}
|
||||
stop()
|
||||
select {
|
||||
case <-linked.Done():
|
||||
case <-time.After(trustFollowAgain):
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// placedAllHave says whether every module the seat mesh-module-assigner places on node has a have of its own, read from the
|
||||
// bus: then the kept declaration is read no more (keptRetired). A node the seat does not place, or whose assignment or haves
|
||||
// cannot be read, keeps reading it.
|
||||
func placedAllHave(ctx context.Context, session *link.Session, node string) (bool, string) {
|
||||
key, err := meshmoduleassignerclient.NodeKey(node)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
// Its node key names every module placed (research 072 row 37).
|
||||
values := map[string][]byte{}
|
||||
if h, err := session.KVHistory(ctx, buckets.Assignments, key); err == nil && len(h) > 0 && !h[len(h)-1].Deleted {
|
||||
values[key] = h[len(h)-1].Value
|
||||
}
|
||||
p, err := placedModules(node, values)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return allHave(p, func(module string) (nodeengine.HaveByNodeModuleValue, bool, error) {
|
||||
return session.ReadHaveErr(ctx, module)
|
||||
})
|
||||
}
|
||||
|
||||
// placedModules is the modules node's node key names (an assignment of their names alone); an error when it does not
|
||||
// read.
|
||||
func placedModules(node string, values map[string][]byte) (meshmoduleassigner.Assignments, error) {
|
||||
key, _ := meshmoduleassignerclient.NodeKey(node)
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
return meshmoduleassigner.Assignments{}, errors.New("its assignment is not read")
|
||||
}
|
||||
n, err := meshmoduleassignerclient.DecodeNode(raw)
|
||||
if err != nil {
|
||||
return meshmoduleassigner.Assignments{}, err
|
||||
}
|
||||
p := meshmoduleassigner.Assignments{Node: node}
|
||||
for _, r := range n.Modules {
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: r.Module})
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// allHave says whether has finds, for every module p places, a have that names a fingerprint of the new form, and why
|
||||
// not. A module whose have names only a build of the old form runs inside the node's own composition, and the kept
|
||||
// declaration is the one record of what runs of it: retired then, the node's own composed it as nothing and refused to
|
||||
// remove what runs (issue 774, 2026-10-30 19:43: systemd-resolved on shanks, nats and restic on novox, each node's own
|
||||
// apply refused from then on).
|
||||
func allHave(p meshmoduleassigner.Assignments, has func(module string) (nodeengine.HaveByNodeModuleValue, bool, error)) (bool, string) {
|
||||
if len(p.Modules) == 0 {
|
||||
return false, "the seat mesh-module-assigner places no module here"
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
h, ok, err := has(m.Module)
|
||||
if err != nil {
|
||||
return false, "the have of " + m.Module + " is not read: " + err.Error()
|
||||
}
|
||||
if !ok {
|
||||
return false, m.Module + " has no have yet"
|
||||
}
|
||||
// A module the mesh provides (mesh-wireguard) never has a fingerprint: the node's own composes it from the manifest
|
||||
// mesh-sdk provides (overlay.ProvidedManifest), never from the kept declaration, so it keeps no read.
|
||||
if h.Phase != nodeengineclient.PhaseRemoved && !strings.HasPrefix(h.Fp, meshcatalogclient.SourcePrefix2) && !overlay.ProvidedByMesh(m.Module) {
|
||||
return false, m.Module + " runs at a build of the old form (" + h.Build + "), which only the kept declaration records"
|
||||
}
|
||||
}
|
||||
return true, fmt.Sprintf("each of the %d modules placed here has a have of its own", len(p.Modules))
|
||||
}
|
||||
|
||||
// certifyThisNode asks the vault's `sign` for this node's own certificate (kind node-certificate, ADR 0550 §2), checks
|
||||
// it is for this node's internal name and serving key and signed by the authority the answer names, which must be the
|
||||
// vault's this node trusts, keeps it where the record reads it, and writes the record again (written).
|
||||
func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust,
|
||||
ask func(ctx context.Context, verb string, args any) (json.RawMessage, error), written func()) error {
|
||||
serving, err := identity.LoadServingKey(identity.ServingKeyPath(state))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(serving.Public)
|
||||
if err != nil || len(key) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("this node's serving key is not an ed25519 key")
|
||||
}
|
||||
raw, err := ask(ctx, meshvault.VerbSign, map[string]any{"kind": meshvaultclient.KindNodeCertificate, "node": node})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var a meshvault.SignAnswer
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return fmt.Errorf("the vault's answer is not a signing: %w", err)
|
||||
}
|
||||
trusted := trust.VaultAuthority()
|
||||
if trusted == "" {
|
||||
return fmt.Errorf("this node has read no authority of the vault's to check the certificate against")
|
||||
}
|
||||
if err := meshvaultclient.CheckNodeCertificate(a.Certificate, []string{trusted}, overlay.InternalName(node),
|
||||
ed25519.PublicKey(key), time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
path := identity.CertificatePath(state)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(a.Certificate), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if written != nil {
|
||||
written()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -9,6 +12,7 @@ import (
|
||||
"github.com/novox/mesh-host/internal/accounts"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/units"
|
||||
)
|
||||
|
||||
@@ -103,6 +107,61 @@ func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) {
|
||||
if got["claude-code.agent"] != "never" || got["openrazer.account"] != "" {
|
||||
t.Fatalf("root said: %v", got)
|
||||
}
|
||||
// 774 (ADR 0563 §6): the node seat's root-free passes only on the engine's own positive verdict at most 15 minutes old
|
||||
// with execute not served here; a stale, missing or negative verdict, or execute served, is no pass and says why.
|
||||
t.Run("this machine's root-free", func(t *testing.T) {
|
||||
agent := accounts.Verdict{Account: accounts.Account{Module: "claude-code", ID: "claude-code.agent", Name: "agent", Root: true},
|
||||
State: accounts.Healthy, Since: at}
|
||||
free := &accounts.Statement{At: at, Accounts: []accounts.Verdict{agent}}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
st *accounts.Statement
|
||||
after time.Duration
|
||||
served bool
|
||||
free bool
|
||||
why string
|
||||
}{
|
||||
{name: "a positive verdict", st: free, after: 14 * time.Minute, free: true, why: "the account agent cannot become root"},
|
||||
{name: "a stale verdict", st: free, after: 16 * time.Minute, why: "more than 15 minutes ago"},
|
||||
{name: "no verdict", st: nil, why: "judged no account yet"},
|
||||
{name: "a way to root", st: as, why: "in the group docker, which grants root"},
|
||||
{name: "no agent account", st: &accounts.Statement{At: at}, why: "agents run as the operator's account"},
|
||||
{name: "execute served", st: free, served: true, why: "the login shell runs any command"},
|
||||
} {
|
||||
v := judgeRootFree("ace", row.st, at.Add(row.after), row.served, "the bus hears execute answered here")
|
||||
if v.Free != row.free || !strings.Contains(v.Why, row.why) || v.Machine != "ace" {
|
||||
t.Errorf("%s: %+v", row.name, v)
|
||||
}
|
||||
}
|
||||
// execute is served while the holder's setting file says anything but withhold, or the bus hears it.
|
||||
dir := t.TempDir()
|
||||
statePath, conf := filepath.Join(dir, "state.json"), filepath.Join(dir, "tools.conf")
|
||||
if err := store.Save(statePath, store.State{Resources: []store.Applied{{ID: "zsh.tools-conf", Type: "file", Target: conf}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quiet := func() (bool, error) { return false, nil }
|
||||
for _, row := range []struct {
|
||||
name, setting string
|
||||
heard func() (bool, error)
|
||||
served bool
|
||||
}{
|
||||
{name: "withheld and not heard", setting: "# the mesh's\nexecute=withhold\n", heard: quiet},
|
||||
{name: "served", setting: "execute=serve\n", heard: quiet, served: true},
|
||||
{name: "withheld and heard", setting: "execute=withhold\n", heard: func() (bool, error) { return true, nil }, served: true},
|
||||
{name: "the bus not asked", setting: "execute=withhold\n", heard: func() (bool, error) { return false, errors.New("no link") }, served: true},
|
||||
{name: "no setting file", heard: quiet, served: true},
|
||||
} {
|
||||
_ = os.Remove(conf)
|
||||
if row.setting != "" {
|
||||
if err := os.WriteFile(conf, []byte(row.setting), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if served, why := executeServedHere(statePath, row.heard); served != row.served {
|
||||
t.Errorf("%s: served %v (%s)", row.name, served, why)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A verdict waiting for the setuid search says how far it got, in the reason the root-free verb repeats and as
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// The last apply's report, kept beside the state until the mesh takes it (novox/hq issue 264).
|
||||
|
||||
// What a host that died between applying and reporting kept is read back by the next one exactly —
|
||||
// the digest and the outcome, never a new account of the machine.
|
||||
func TestTheKeptReportSurvivesTheHost(t *testing.T) {
|
||||
state := filepath.Join(t.TempDir(), "state.json")
|
||||
made := link.Report{Declared: "sha256:d1", Applied: []string{"a"}, Failed: map[string]string{"b": "no room"},
|
||||
Host: "v2"}
|
||||
if err := unsaidBeside(state).Keep(made); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
kept, ok, err := unsaidBeside(state).Pending()
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("nothing kept: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(kept, made) {
|
||||
t.Fatalf("kept %+v, made %+v", kept, made)
|
||||
}
|
||||
|
||||
// Taken about another declaration, it stays; taken about this one, it goes.
|
||||
if err := unsaidBeside(state).Said("sha256:other"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := unsaidBeside(state).Pending(); !ok {
|
||||
t.Fatal("a report about another declaration cleared this one")
|
||||
}
|
||||
if err := unsaidBeside(state).Said("sha256:d1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(store.UnsaidPath(state)); !os.IsNotExist(err) {
|
||||
t.Fatalf("the report was taken and is still kept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A node that never applied anything has nothing to say again; and an apply refused before its
|
||||
// declaration was read clears an older report, which would otherwise be re-said after it.
|
||||
func TestNothingKeptWhenNothingWasApplied(t *testing.T) {
|
||||
state := filepath.Join(t.TempDir(), "state.json")
|
||||
if _, ok, err := unsaidBeside(state).Pending(); ok || err != nil {
|
||||
t.Fatalf("a node that applied nothing has a report to say: %v %v", ok, err)
|
||||
}
|
||||
if err := unsaidBeside(state).Keep(link.Report{Declared: "sha256:d1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := unsaidBeside(state).Keep(link.Report{Refused: "forged"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := unsaidBeside(state).Pending(); ok {
|
||||
t.Fatal("an older report is kept after a later declaration was refused")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
"github.com/novox/mesh-host/internal/userlist"
|
||||
)
|
||||
|
||||
// The bridge of novox/hq ADR 0547 §8, wired: the bus node's engine writes the grants part's user list alone into the file
|
||||
// the bus module's composition declares, under the apply's two locks as a filled file is written (filling.go), and says what
|
||||
// it wrote in the bus module's have.
|
||||
|
||||
// userListFollower is the follower of the bus's user list for one link: the node's composition bucket, the keys the
|
||||
// agent trusts, the write alone, and the have.
|
||||
func userListFollower(opts options, session *link.Session, running func() []engcomp.Composed,
|
||||
say link.Announce, wrote func()) *userlist.Follower {
|
||||
return userlist.New(userlist.Env{
|
||||
Node: session.Membership.Node,
|
||||
Running: running,
|
||||
OnDisk: userListOnDisk,
|
||||
Write: func(ctx context.Context, module string, raw json.RawMessage, body []byte) (string, bool, error) {
|
||||
digest, changed, err := writeUserListAlone(session.WithLink(ctx), opts, raw, body, say)
|
||||
if changed && wrote != nil {
|
||||
wrote()
|
||||
}
|
||||
return digest, changed, err
|
||||
},
|
||||
Report: func(ctx context.Context, module string, w userlist.Written) {
|
||||
session.NoteUserList(ctx, module, w)
|
||||
},
|
||||
// From take-over, the grants part's list (ADR 0547 clauses 7, 8): its one key watched, its object read by name and
|
||||
// opened with this machine's sealing key, the key's public half said in the have so the grants part seals to it.
|
||||
Grants: bucketWatch{session, grantsstate.GrantsUserListBucket},
|
||||
Fetch: func(ctx context.Context, name string) ([]byte, error) {
|
||||
return session.GrantsObject(ctx, name, grantsListLimit)
|
||||
},
|
||||
Open: func(b seal.Box) ([]byte, error) {
|
||||
key, err := identity.LoadSealingKey(identity.SealingKeyPath(opts.state))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key.OpenVaultBox(b)
|
||||
},
|
||||
Key: func() string {
|
||||
key, err := identity.LoadSealingKey(identity.SealingKeyPath(opts.state))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
pem, err := key.PublicPEM()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return pem
|
||||
},
|
||||
// The grants part's key as last heard, kept beside the node's state, followed while it cannot be watched; its
|
||||
// watch tried again as a passing failure of the bus is (ADR 0547 clause 8).
|
||||
Kept: func() ([]byte, uint64) { return keptGrantsKey(opts) },
|
||||
Keep: func(value []byte, rev uint64) { keepGrantsKey(opts, value, rev, say) },
|
||||
Backoff: link.Backoff,
|
||||
Say: announceOr(say),
|
||||
})
|
||||
}
|
||||
|
||||
// grantsListLimit is the largest sealed list the bus node reads: the live list is about a hundred kilobytes.
|
||||
const grantsListLimit = 16 << 20
|
||||
|
||||
// userListFile is a user-list resource as the file it declares.
|
||||
func userListFile(raw []byte) (*declaration.File, error) {
|
||||
r, err := declaration.ParseResource(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the user list is not a resource: %w", err)
|
||||
}
|
||||
f, ok := r.(*declaration.File)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the user list is a %s, not a file", r.Kind())
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// userListOnDisk is what the user list's file holds on this machine now; nil when there is none.
|
||||
func userListOnDisk(raw json.RawMessage) ([]byte, error) {
|
||||
f, err := userListFile(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := os.ReadFile(f.Path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return body, err
|
||||
}
|
||||
|
||||
// writeUserListAlone writes one user-list resource alone (apply.WriteAlone) with body as its content, the node's state
|
||||
// read and saved under the apply's locks, and answers the digest of the file on the machine after.
|
||||
func writeUserListAlone(ctx context.Context, opts options, raw []byte, body []byte, say link.Announce) (string, bool, error) {
|
||||
declared, err := userListFile(raw)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
file := *declared
|
||||
file.Content, file.Digest, file.Size = string(body), "", 0
|
||||
|
||||
applying.Lock()
|
||||
defer applying.Unlock()
|
||||
unlock, err := store.Lock(opts.state, nil)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
defer unlock()
|
||||
known, err := store.Load(opts.state)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
built, err := system.For(builtFor)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
out, updated, writeErr := apply.WriteAlone(ctx, built, &file, known, apply.ExecRunner, announceOr(say))
|
||||
// Saved whichever way it went, as an apply's: what was written is on the machine either way.
|
||||
if err := store.Save(opts.state, updated); err != nil {
|
||||
writeErr = errors.Join(writeErr, errors.New("written, and the node's state could not be saved: "+err.Error()))
|
||||
}
|
||||
digest := ""
|
||||
if body, err := os.ReadFile(file.Path); err == nil {
|
||||
digest = userlist.Digest(body)
|
||||
}
|
||||
return digest, writeErr == nil && out.Action != "unchanged", writeErr
|
||||
}
|
||||
|
||||
// grantsKeyPath is where the grants part's user-list key last heard is kept, beside the node's state.
|
||||
func grantsKeyPath(opts options) string {
|
||||
return filepath.Join(filepath.Dir(opts.state), "grants-user-list.json")
|
||||
}
|
||||
|
||||
type keptKey struct {
|
||||
Revision uint64 `json:"revision"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
|
||||
func keptGrantsKey(opts options) ([]byte, uint64) {
|
||||
raw, err := os.ReadFile(grantsKeyPath(opts))
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
var k keptKey
|
||||
if json.Unmarshal(raw, &k) != nil || len(k.Value) == 0 {
|
||||
return nil, 0
|
||||
}
|
||||
return k.Value, k.Revision
|
||||
}
|
||||
|
||||
func keepGrantsKey(opts options, value []byte, rev uint64, say link.Announce) {
|
||||
path := grantsKeyPath(opts)
|
||||
if value == nil {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
announceOr(say)("the grants part's user-list key kept on this machine could not be taken away: " + err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(keptKey{Revision: rev, Value: value})
|
||||
tmp := path + ".new"
|
||||
err := os.WriteFile(tmp, raw, 0o600)
|
||||
if err == nil {
|
||||
err = os.Rename(tmp, path)
|
||||
}
|
||||
if err != nil {
|
||||
announceOr(say)("the grants part's user-list key could not be kept on this machine: " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
@@ -61,14 +60,14 @@ func TestNatsTheEngineFillsACoreCredentialFromTheVaultsBoxWithNoValueInTheClear(
|
||||
}
|
||||
_ = tap.Flush()
|
||||
|
||||
// The engine hears its declaration on its own subject.
|
||||
const node = "vaultbox"
|
||||
// The engine hears the composition on a subject of the test (the node agent reads it from its bucket).
|
||||
const subject = "mesh.test.vaultbox.composition"
|
||||
engine, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer engine.Close()
|
||||
heard, err := engine.SubscribeSync(link.DeclareSubject(node))
|
||||
heard, err := engine.SubscribeSync(subject)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -92,7 +91,7 @@ func TestNatsTheEngineFillsACoreCredentialFromTheVaultsBoxWithNoValueInTheClear(
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer controller.Close()
|
||||
if err := controller.Publish(link.DeclareSubject(node), body); err != nil {
|
||||
if err := controller.Publish(subject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = controller.Flush()
|
||||
@@ -123,7 +122,7 @@ func TestNatsTheEngineFillsACoreCredentialFromTheVaultsBoxWithNoValueInTheClear(
|
||||
mu.Lock()
|
||||
all := strings.Join(crossed, "\n")
|
||||
mu.Unlock()
|
||||
if !strings.Contains(all, link.DeclareSubject(node)) || !strings.Contains(all, "vault-sealed") {
|
||||
if !strings.Contains(all, subject) || !strings.Contains(all, "vault-sealed") {
|
||||
t.Fatalf("the listener heard nothing of the declaration:\n%s", all)
|
||||
}
|
||||
for _, form := range []string{coreCredential, base64.StdEncoding.EncodeToString([]byte(coreCredential))} {
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/filling"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
func aFileOf(module, id string) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]any{"id": module + "." + id, "type": "file", "path": "/tmp/r6/" + module + "/" + id,
|
||||
"content": "x", "module": module})
|
||||
return raw
|
||||
}
|
||||
|
||||
// What runs is the agent's compositions and the kept declaration's resources for each module the agent does not hold;
|
||||
// the node's own parts are the kept declaration's until the agent holds the node's composition (ADR 0539 §9). One module
|
||||
// under the agent must not make the rest invisible to the liveness, units, accounts, libraries, filling and the take act.
|
||||
func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
|
||||
kept := &declaration.Declaration{Identities: []declaration.Identity{{Module: "db", Name: "mesh-db"}},
|
||||
Libraries: []declaration.Library{{Library: "media"}}}
|
||||
everything := []engcomp.Composed{
|
||||
{Node: "n1", Module: "web", Resources: []json.RawMessage{aFileOf("web", "a")}},
|
||||
{Node: "n1", Module: "db", Resources: []json.RawMessage{aFileOf("db", "a")}}, // still the kept declaration's
|
||||
}
|
||||
d, err := whatRuns(everything, false, func() (*declaration.Declaration, error) { return kept, nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d.Resources) != 2 || len(d.Identities) != 1 || len(d.Libraries) != 1 {
|
||||
t.Fatalf("%d resources, %d identities, %d libraries: the kept modules and the node's kept parts are missing",
|
||||
len(d.Resources), len(d.Identities), len(d.Libraries))
|
||||
}
|
||||
|
||||
// Once the agent holds the node's composition, its parts are the node's.
|
||||
node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Identities: []declaration.Identity{{Module: "web", Name: "mesh-web"}}}}
|
||||
d, err = whatRuns(append(everything, node), true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d.Identities) != 1 || d.Identities[0].Name != "mesh-web" || len(d.Libraries) != 0 {
|
||||
t.Fatalf("the node's parts: %+v %+v", d.Identities, d.Libraries)
|
||||
}
|
||||
}
|
||||
|
||||
// What the take act, the judgement of directories and the filling read is what runs, not the frozen kept declaration.
|
||||
func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
|
||||
t.Run("a fill while the agent restarts", aFillWhileTheAgentRestartsIsWrittenAfterwards)
|
||||
setRunsHere(func(string, ed25519.PublicKey) (*declaration.Declaration, error) {
|
||||
return &declaration.Declaration{Version: 1, Resources: nil, Provisioner: "from-agent"}, nil
|
||||
})
|
||||
defer setRunsHere(nil)
|
||||
d, err := declaredNow("/nowhere", nil)
|
||||
if err != nil || d.Provisioner != "from-agent" {
|
||||
t.Fatalf("declaredNow read %+v (%v), not what runs", d, err)
|
||||
}
|
||||
|
||||
// And the health loop's judges are handed what runs once a module settles (g14 and shanks, 2026-10-30 16:14):
|
||||
// restic's composition dropped its process restic.measure, which stayed watched and said down on every look.
|
||||
j, err := liveness.Open(filepath.Join(t.TempDir(), "liveness.json"), fakeRuntime{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
judging = j
|
||||
defer func() { judging = nil }()
|
||||
process := func(name string) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]any{"id": "restic." + name, "type": "process", "name": "restic-" + name,
|
||||
"source": "https://store.invalid/restic", "digest": "sha256:" + strings.Repeat("a", 64),
|
||||
"run": []string{"./restic"}, "module": "restic"})
|
||||
return raw
|
||||
}
|
||||
for _, row := range []struct {
|
||||
runs []string
|
||||
watch string
|
||||
}{{[]string{"night", "measure"}, "restic.measure restic.night"}, {[]string{"night"}, "restic.night"}} {
|
||||
var raws []json.RawMessage
|
||||
for _, name := range row.runs {
|
||||
raws = append(raws, process(name))
|
||||
}
|
||||
runs, err := whatRuns([]engcomp.Composed{{Node: "n1", Module: "restic", Resources: raws}}, false,
|
||||
func() (*declaration.Declaration, error) { return nil, errors.New("none kept") })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setRunsHere(func(string, ed25519.PublicKey) (*declaration.Declaration, error) { return runs, nil })
|
||||
judgeWhatRunsNow(filepath.Join(t.TempDir(), "state.json"), nil)
|
||||
st, _ := j.Look(context.Background())
|
||||
var watched []string
|
||||
for _, r := range st.Resources {
|
||||
watched = append(watched, r.ID)
|
||||
}
|
||||
sort.Strings(watched)
|
||||
if got := strings.Join(watched, " "); got != row.watch {
|
||||
t.Errorf("running %v, the judge watches %q, want %q", row.runs, got, row.watch)
|
||||
}
|
||||
}
|
||||
|
||||
// L3 s9 (ADR 0550 §2): a node whose placed modules all have a have reads the kept declaration no more, though the
|
||||
// file is there and readable and the controller's key is dropped; until then it reads it.
|
||||
state := filepath.Join(t.TempDir(), "state", "state.json")
|
||||
if err := store.SaveDeclared(store.DeclaredPath(state), store.Declared{Declaration: []byte(`{"declaration":1,` +
|
||||
`"resources":[{"id":"zsh.rc","type":"file","module":"zsh","path":"/etc/zshrc","content":"x"}]}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d, err := keptDeclaration(state, nil); err != nil || len(d.Resources) != 1 {
|
||||
t.Fatalf("before the read is retired: %+v, %v", d, err)
|
||||
}
|
||||
placed := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}}
|
||||
// Issue 774, 2026-10-30 19:43: a have naming only a build of the old form (systemd-resolved on shanks, folded in the
|
||||
// node's own) keeps the kept declaration read, since it is the one record of what runs of it.
|
||||
newForm := nodeengine.HaveByNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning}
|
||||
oldForm := nodeengine.HaveByNodeModuleValue{Build: "build-1791404191702719760", Phase: nodeengineclient.PhaseRunning}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
has map[string]nodeengine.HaveByNodeModuleValue
|
||||
want bool
|
||||
}{
|
||||
{"every placed module has a have of the new form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": newForm}, true},
|
||||
{"one has none yet", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm}, false},
|
||||
{"one runs at a build of the old form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": oldForm}, false},
|
||||
} {
|
||||
if got, why := allHave(placed, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) { h, ok := row.has[m]; return h, ok, nil }); got != row.want {
|
||||
t.Errorf("%s: %v (%s)", row.name, got, why)
|
||||
}
|
||||
}
|
||||
// The module the mesh provides never has a fingerprint, and keeps no read (review of issue 774).
|
||||
withWireguard := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}}
|
||||
if got, why := allHave(withWireguard, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) {
|
||||
if m == overlay.Name {
|
||||
return oldForm, true, nil
|
||||
}
|
||||
return newForm, true, nil
|
||||
}); !got {
|
||||
t.Errorf("%s without a fingerprint beside zsh at the new form keeps the read: %s", overlay.Name, why)
|
||||
}
|
||||
if got, _ := allHave(meshmoduleassigner.Assignments{Node: "n1"}, nil); got {
|
||||
t.Error("a node with nothing placed retires the read")
|
||||
}
|
||||
keptRetired.Store(true)
|
||||
defer keptRetired.Store(false)
|
||||
if _, err := keptDeclaration(state, nil); !errors.Is(err, errKeptRetired) {
|
||||
t.Errorf("after the read is retired: %v", err)
|
||||
}
|
||||
if by, filled := keptOnce(options{state: state}, nil)(); by != nil || filled != nil {
|
||||
t.Errorf("after the read is retired the kept declaration was split: %v %v", by, filled)
|
||||
}
|
||||
}
|
||||
|
||||
// 1945/AC17 (unit): a resource the node's own composition shares with a running module's composition is declared once,
|
||||
// the module's: the declaration joined of both names each file once, so the filling writes it; the node's own resources
|
||||
// no module declares stay. (Live from 2026-10-25 12:43: "2 resources declare X" on every node.)
|
||||
func TestTheNodesOwnCopyOfAModulesResourceIsLeftOut(t *testing.T) {
|
||||
own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")},
|
||||
Filled: []string{"/tmp/r6/zsh/rc"}}
|
||||
zsh := engcomp.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")},
|
||||
Filled: []string{"/tmp/r6/zsh/rc"}}
|
||||
d, err := declarationOf(own, zsh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := map[string]int{}
|
||||
for _, r := range d.Resources {
|
||||
at[r.Target()]++
|
||||
}
|
||||
if at["/tmp/r6/zsh/rc"] != 1 || at["/tmp/r6/mesh-wireguard/conf"] != 1 || len(d.Resources) != 2 {
|
||||
t.Errorf("the joined declaration declares %v, want each path once", at)
|
||||
}
|
||||
if !d.Fills("/tmp/r6/zsh/rc") {
|
||||
t.Errorf("the filled path is no longer named")
|
||||
}
|
||||
|
||||
// The same at the node's own apply: what the others declare is theirs.
|
||||
trimmed, dropped := ownWithout(own, []engcomp.Composed{zsh})
|
||||
if len(trimmed.Resources) != 1 || len(dropped) != 1 || dropped[0] != "zsh.rc (zsh)" {
|
||||
t.Errorf("the node's own without zsh's: %d resources, dropped %v", len(trimmed.Resources), dropped)
|
||||
}
|
||||
if len(own.Resources) != 2 {
|
||||
t.Errorf("ownWithout changed its argument")
|
||||
}
|
||||
// A node's own composition alone is all of it.
|
||||
if d, err := declarationOf(own); err != nil || len(d.Resources) != 2 {
|
||||
t.Errorf("the node's own alone: %v (%v)", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1945/AC18 (unit): the kept declaration's filled paths go to the module whose file is at each path, the node's own to
|
||||
// the node's; and what runs from it, joined with the node's composition, names them filled.
|
||||
func TestTheKeptDeclarationsFilledPathsGoToTheirModules(t *testing.T) {
|
||||
raw := []byte(`{"resources":[
|
||||
{"id":"claude-code.settings","type":"file","module":"claude-code","path":"/etc/cc.json"},
|
||||
{"id":"screen-lock.conf","type":"file","module":"screen-lock","path":"/etc/lock.conf"},
|
||||
{"id":"host.motd","type":"file","path":"/etc/motd"},
|
||||
{"id":"zsh.rc","type":"file","module":"zsh","path":"/etc/zshrc"}],
|
||||
"filled_files":["/etc/cc.json","/etc/lock.conf","/etc/motd"]}`)
|
||||
by, filled := splitKept(raw)
|
||||
if len(by["claude-code"]) != 1 || len(by[agent.NodeModule]) != 1 {
|
||||
t.Fatalf("resources by module: %v", by)
|
||||
}
|
||||
if strings.Join(filled["claude-code"], ",") != "/etc/cc.json" || strings.Join(filled["screen-lock"], ",") != "/etc/lock.conf" ||
|
||||
strings.Join(filled[agent.NodeModule], ",") != "/etc/motd" || len(filled["zsh"]) != 0 {
|
||||
t.Fatalf("filled by module: %v", filled)
|
||||
}
|
||||
cc := engcomp.Composed{Node: "n1", Module: "claude-code", Resources: by["claude-code"], Filled: filled["claude-code"]}
|
||||
node := engcomp.Composed{Node: "n1"}
|
||||
d, err := whatRuns([]engcomp.Composed{cc, node}, true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
|
||||
if err != nil || !d.Fills("/etc/cc.json") {
|
||||
t.Fatalf("what runs does not name claude-code's file filled: %v (%v)", d, err)
|
||||
}
|
||||
if splitBy, _ := splitKept([]byte("not json")); splitBy != nil {
|
||||
t.Errorf("a kept declaration that does not read split into %v", splitBy)
|
||||
}
|
||||
}
|
||||
|
||||
// 1945/AC17 (unit, review): a resource of the node's own that shares only its target with a module's (another id: a
|
||||
// unit or a container of the same name) is not the module's, and is neither dropped nor left to be removed.
|
||||
func TestASharedTargetWithAnotherIDIsNotDropped(t *testing.T) {
|
||||
ownFile, _ := json.Marshal(map[string]any{"id": "host.zshrc", "type": "file", "path": "/tmp/r6/zsh/rc", "content": "x"})
|
||||
own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{ownFile}}
|
||||
zsh := engcomp.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}}
|
||||
trimmed, dropped := ownWithout(own, []engcomp.Composed{zsh})
|
||||
if len(dropped) != 0 || len(trimmed.Resources) != 1 {
|
||||
t.Errorf("a resource sharing only its target was dropped: %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 924: the bus link drops, the agent restarts, and the filling is asked while it holds nothing yet. What runs is
|
||||
// then the last view the node held, never the retired kept declaration; and a fill refused for want of any is owed and
|
||||
// told once the agent holds the node again, so every file is written afterwards.
|
||||
func aFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) {
|
||||
keptRetired.Store(true)
|
||||
defer keptRetired.Store(false)
|
||||
node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Provisioner: "mesh-node-provisioner"}}
|
||||
files := []engcomp.Composed{{Node: "n1", Module: "db", Resources: []json.RawMessage{aFileOf("db", "a")}, Filled: []string{"/tmp/r6/db/a"}}}
|
||||
view := &runsView{everything: append(files, node), holdsNode: true}
|
||||
|
||||
var placed []string
|
||||
// The agent holds the node, then the link drops and it stops: set(nil) keeps its view.
|
||||
holding := true
|
||||
current := &agentNow{view: func(*agent.Agent) *runsView {
|
||||
if holding {
|
||||
return view
|
||||
}
|
||||
return &runsView{}
|
||||
}}
|
||||
current.set(agent.New(agent.Env{}))
|
||||
e := &filling.Engine{
|
||||
Declared: func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) },
|
||||
UIDOf: func(string) (uint32, error) { return 4242, nil },
|
||||
Lock: func() (func(), error) { return func() {}, nil },
|
||||
Place: func(_ context.Context, _ *declaration.Declaration, path string, _ []byte) (string, string, error) {
|
||||
placed = append(placed, path)
|
||||
return "updated", "d", nil
|
||||
},
|
||||
}
|
||||
fill := func() filling.Answer {
|
||||
return e.Write(context.Background(), 4242, filling.Request{Path: "/tmp/r6/db/a", Content: []byte("x")})
|
||||
}
|
||||
socket := filepath.Join(t.TempDir(), "sock")
|
||||
if err := os.WriteFile(socket, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The next agent has read no haves and the previous one's view is gone (a cold start): refused, and owed.
|
||||
cold := &agentNow{}
|
||||
cold.set(agent.New(agent.Env{}))
|
||||
cold.set(nil)
|
||||
e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(cold)("/nowhere", nil) }
|
||||
if a := fill(); a.Failed == "" && a.Refused == "" {
|
||||
t.Fatalf("a fill with nothing read was accepted: %+v", a)
|
||||
}
|
||||
// The link drops: set(nil) keeps the view the agent held, and the fill during the restart is written.
|
||||
e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) }
|
||||
current.set(nil)
|
||||
if told, err := e.Knock(socket); err != nil || told == "" {
|
||||
t.Fatalf("the refused fill was not owed and told: %q, %v", told, err)
|
||||
}
|
||||
if a := fill(); a.Failed != "" || a.Refused != "" || len(placed) != 1 {
|
||||
t.Fatalf("the fill during the restart was not written: %+v, placed %v", a, placed)
|
||||
}
|
||||
// The new agent holds the node: its own view answers and the kept one is dropped.
|
||||
next := agent.New(agent.Env{})
|
||||
current.set(next)
|
||||
holding = true
|
||||
if _, err := runsFromAgent(current)("/nowhere", nil); err != nil || current.lastView() != nil {
|
||||
t.Fatalf("the kept view stays after the new agent holds the node: %v, %v", err, current.lastView())
|
||||
}
|
||||
}
|
||||
+11
-13
@@ -41,8 +41,9 @@ func rolledBackHosts(statePath string) []string {
|
||||
return upgrade.RolledBackVersions(verdicts)
|
||||
}
|
||||
|
||||
// proving is this host waiting for the mesh to take a report it made under its own build — the
|
||||
// evidence its launcher's trial waits for — and acting on it once.
|
||||
// proving is this host waiting to write a have key under its own build — the evidence its launcher's trial waits for
|
||||
// (novox/hq ADR 0539 §9, ADR 0227 §8: the component replaced is never the only witness of its successor) — and acting
|
||||
// on it once.
|
||||
type proving struct {
|
||||
statePath string
|
||||
version string
|
||||
@@ -56,16 +57,14 @@ func unproved(statePath, version string) bool {
|
||||
return err != nil || known != version
|
||||
}
|
||||
|
||||
// heard is told every account of the machine the mesh has taken. The first one about a declaration,
|
||||
// made by this build, proves it.
|
||||
func (p *proving) heard(r link.Report) {
|
||||
if r.Declared == "" || r.Refused != "" || r.Host != p.version {
|
||||
return
|
||||
}
|
||||
// haveWritten is told when this build has written a have key, or found the bucket already holding what it would write.
|
||||
// The first proves it. **A declaration's report does not**: a node-engine that receives none is not on trial for it, and
|
||||
// one that cannot write its haves is what the launcher keeps rolling back.
|
||||
func (p *proving) haveWritten() {
|
||||
p.once.Do(func() {
|
||||
retired, err := upgrade.Proved(p.statePath, upgrade.VersionsDir(""), p.version)
|
||||
if err != nil {
|
||||
p.say(fmt.Sprintf("this host reported under its own build %s, and proving it was not complete: %v", p.version, err))
|
||||
p.say(fmt.Sprintf("this host wrote its first have under its own build %s, and proving it was not complete: %v", p.version, err))
|
||||
}
|
||||
if len(retired) > 0 {
|
||||
p.say(fmt.Sprintf("host %s is proved; retired the host version(s) %s", p.version, strings.Join(retired, ", ")))
|
||||
@@ -146,10 +145,9 @@ func launcherReplaced(pid int, proc, path string) bool {
|
||||
}
|
||||
|
||||
// watchWhatThisHostPlaced judges the core builds this host placed, until ctx ends (to-be 45 §8).
|
||||
func watchWhatThisHostPlaced(ctx context.Context, node string, queue *link.Queue, say func(string)) {
|
||||
host, _ := os.Hostname()
|
||||
func watchWhatThisHostPlaced(ctx context.Context, node string, queue *link.Session, say func(string)) {
|
||||
w := &witness.Watcher{
|
||||
Root: apply.DaemonRoot(), Node: node, Host: host,
|
||||
Root: apply.DaemonRoot(), Node: node,
|
||||
Asker: func() link.Asker { return queue.Asker() },
|
||||
Run: witness.Runner(apply.ExecRunner),
|
||||
Hold: func(f func()) {
|
||||
@@ -159,7 +157,7 @@ func watchWhatThisHostPlaced(ctx context.Context, node string, queue *link.Queue
|
||||
},
|
||||
// Said now, not at the next report: the verdict is on every report from here, and the mesh
|
||||
// is asked for one at once.
|
||||
Concluded: func(link.Rollback) { queue.ReportAsked() },
|
||||
Concluded: func(link.Rollback) { queue.FactsAsked(ctx) },
|
||||
Say: say,
|
||||
}
|
||||
w.Watch(ctx)
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"github.com/novox/mesh-host/internal/upgrade"
|
||||
)
|
||||
|
||||
// The first account the mesh takes from this build, about a declaration, proves it to the launcher;
|
||||
// nothing else does — a refusal, a report about no declaration, another build's report (to-be 45 §8).
|
||||
func TestOnlyAnAccountUnderThisBuildProvesIt(t *testing.T) {
|
||||
// The first have this build writes proves it to the launcher; nothing else does — not a report about a declaration, not
|
||||
// a facts event, not the haves of another build (novox/hq ADR 0539 §9, ADR 0227 §8). R6-A AC7.
|
||||
func TestOnlyAHaveUnderThisBuildProvesIt(t *testing.T) {
|
||||
state := filepath.Join(t.TempDir(), "state.json")
|
||||
t.Setenv("MESH_HOST_LIBEXEC", t.TempDir())
|
||||
// Never the machine's own engine placed by hand: proving renews it (issue 356).
|
||||
@@ -27,22 +27,20 @@ func TestOnlyAnAccountUnderThisBuildProvesIt(t *testing.T) {
|
||||
var said []string
|
||||
p := &proving{statePath: state, version: "2.0", say: func(s string) { said = append(said, s) }}
|
||||
|
||||
for _, r := range []link.Report{
|
||||
{Host: "2.0", Refused: "no"},
|
||||
{Host: "2.0"},
|
||||
{Host: "1.0", Declared: "abc"},
|
||||
} {
|
||||
p.heard(r)
|
||||
if !unproved(state, "2.0") {
|
||||
t.Fatalf("%+v proved this build", r)
|
||||
}
|
||||
if got, _ := os.ReadFile(fallback); string(got) != "a stale engine placed by hand" {
|
||||
t.Fatalf("%+v renewed the engine placed by hand", r)
|
||||
}
|
||||
// Facts said, a link opened, a declaration of the old kind reported: none of it is a have.
|
||||
if !unproved(state, "2.0") {
|
||||
t.Fatal("a build that wrote no have is proved")
|
||||
}
|
||||
p.heard(link.Report{Host: "2.0", Declared: "abc"})
|
||||
if got, _ := os.ReadFile(fallback); string(got) != "a stale engine placed by hand" {
|
||||
t.Fatal("the engine placed by hand was renewed before any have")
|
||||
}
|
||||
|
||||
// The session tells the proof when it has a have key saying what it would write.
|
||||
session := &link.Session{HaveWritten: p.haveWritten}
|
||||
_ = session
|
||||
p.haveWritten()
|
||||
if unproved(state, "2.0") {
|
||||
t.Fatalf("an account of a declaration under this build did not prove it (%v)", said)
|
||||
t.Fatalf("the first have under this build did not prove it (%v)", said)
|
||||
}
|
||||
if got, _ := upgrade.ReadKnownGood(upgrade.KnownGoodPath(state)); got != "2.0" {
|
||||
t.Fatalf("known-good is %q", got)
|
||||
@@ -78,12 +76,6 @@ func TestAStandingRollbackIsSaidOnEveryReport(t *testing.T) {
|
||||
got[0].Outcome != link.RolledBack {
|
||||
t.Fatalf("what the report says is %+v", got)
|
||||
}
|
||||
if !worthSaying(link.Report{Rollbacks: got}) {
|
||||
t.Fatal("a reconcile with a rollback standing does not say it")
|
||||
}
|
||||
if adoptionFingerprint(link.Report{Rollbacks: got}) == adoptionFingerprint(link.Report{}) {
|
||||
t.Fatal("a rollback reached or ended is not news to the reconcile")
|
||||
}
|
||||
}
|
||||
|
||||
// The launcher running this host is known to have been replaced only when it certainly was: a shell
|
||||
|
||||
@@ -221,7 +221,7 @@
|
||||
"path": "/var/lib/nats-module/conf/accounts.conf",
|
||||
"mode": "0600",
|
||||
"create-once": true,
|
||||
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_asked.>\", \"$KV.mesh-controller_assignments.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.mesh-controller_declared-config.>\", \"$KV.mesh-controller_lease.>\", \"$KV.mesh-controller_predefined.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.plan-moved\", \"mesh.seat.mesh-controller.event.assignment-changed\", \"mesh.seat.mesh-controller.event.declared-config-changed\", \"mesh.seat.mesh-controller.event.predefined-changed\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.event.healer-acted\", \"$SRV.INFO\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.operator-channel.tool.tell\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.events.dead.>\", \"mesh.again.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
|
||||
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.ACK.DEAD_LETTER_NOTICES.controller.>\", \"$JS.API.>\", \"$KV.mesh-controller_asked.>\", \"$KV.mesh-controller_assignments.>\", \"$KV.mesh-controller_calls.>\", \"$KV.mesh-controller_hand-acts.>\", \"$KV.mesh-controller_conditions.>\", \"$KV.mesh-controller_condition-history.>\", \"$KV.mesh-controller_declared-config.>\", \"$KV.mesh-controller_lease.>\", \"$KV.mesh-controller_predefined.>\", \"$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>\", \"$KV.SEAT_NODE_BUILD_AGENT_cancelled.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.node-build-agent.accept.>\", \"mesh.seat.mesh-build-machine.tool.>\", \"mesh.seat.node-build-agent.tool.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\", \"mesh.seat.mesh-controller.event.rolled-back\", \"mesh.seat.mesh-controller.event.checked\", \"mesh.seat.mesh-controller.event.assignment-changed\", \"mesh.seat.mesh-controller.event.declared-config-changed\", \"mesh.seat.mesh-controller.event.predefined-changed\", \"mesh.seat.mesh-controller.event.condition-raised\", \"mesh.seat.mesh-controller.event.condition-changed\", \"mesh.seat.mesh-controller.event.condition-cleared\", \"mesh.seat.mesh-controller.event.doctor-heartbeat\", \"mesh.seat.mesh-controller.event.secret-replaced\", \"mesh.seat.mesh-controller.event.healer-acted\", \"$SRV.INFO\", \"mesh.seat.node-intrusion-prevention.tool.banned.*\", \"mesh.seat.node-backup.tool.backed-up.*\", \"mesh.seat.node-backup.tool.now.*\", \"mesh.seat.mesh-delivery.tool.stalled\", \"mesh.seat.mesh-delivery.tool.close\", \"mesh.seat.node-launcher.tool.secret.*\", \"mesh.seat.mesh-delivery.tool.release\", \"mesh.seat.mesh-delivery.tool.stop\", \"mesh.seat.mesh-delivery.tool.times\", \"mesh.seat.mesh-delivery.tool.groups\", \"mesh.seat.node-service-manager.tool.restart.*\", \"mesh.seat.operator-channel.tool.tell\", \"mesh.seat.mesh-controller.tool.plans\", \"mesh.events.dead.>\", \"mesh.again.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>\", \"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.*.event.provisioner.failing\", \"mesh.mod.*.event.provisioner.recovered\", \"mesh.mod.*.event.provisioner.retirement\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.gitea.event.pull.updated\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.node-build-agent.event.built\", \"mesh.seat.operator-channel.event.decided.mesh-controller\", \"mesh.seat.mesh-controller.tool.>\", \"$SRV.PING\", \"$SRV.INFO\", \"$SRV.PING.mesh-controller\", \"$SRV.PING.mesh-controller.>\", \"$SRV.INFO.mesh-controller\", \"$SRV.INFO.mesh-controller.>\", \"$SRV.STATS\", \"$SRV.STATS.mesh-controller\", \"$SRV.STATS.mesh-controller.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
|
||||
},
|
||||
// The upstream server, until the broker step adopts it: the `nats` module's own image, built by the
|
||||
// mesh, takes its place there once, on these same directories (ADR 0333 §3), as the bus's planned
|
||||
|
||||
@@ -3,14 +3,49 @@ module github.com/novox/mesh-host
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021153908-725590e06e75
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/sys v0.48.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork v0.0.0-00010101000000-000000000000 // indirect
|
||||
)
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021153908-725590e06e75 h1:AcYfyJpYaUufhI93VG76+b001msXY4XX7kuRjnf33HY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021153908-725590e06e75/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
@@ -10,5 +18,8 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
|
||||
@@ -240,6 +240,17 @@ func (c *SetuidCache) SearchAgain() {
|
||||
c.forgetProgress()
|
||||
}
|
||||
|
||||
// Running says whether a search walks now (novox/hq ADR 0563 §2: the node seat starts no second while one does).
|
||||
// Nil c runs none.
|
||||
func (c *SetuidCache) Running() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.running
|
||||
}
|
||||
|
||||
// SearchNow throws away the last complete search, kept or in memory, and any walk under way or kept, and has
|
||||
// the next look start a full search at once, past AgainAfter and any back-off (novox/hq issue 361): the
|
||||
// operator changed by hand what the last one found, which no apply says. Until the new one completes the
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/judge"
|
||||
)
|
||||
|
||||
// The node-engine seat's read verb composition (issue 774): one module's composition beside what runs of it, so why a
|
||||
// record module waits for a yes, or what its next apply would change, is read from the engine and not guessed from its
|
||||
// have's words. It changes nothing.
|
||||
|
||||
// CompositionView is what the verb composition answers.
|
||||
type CompositionView struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
// Want is the module's want as the agent holds it; nil when it holds none.
|
||||
Want *WantView `json:"want,omitempty"`
|
||||
// Attempt is what the agent holds of the module's last apply that did not end running: failed, failed for a moment
|
||||
// of the bus, or held for a failed backup, with the want's revision that hold took as answered; nil when none
|
||||
// (issue 1585: why a module stays held was not readable from the engine).
|
||||
Attempt *AttemptView `json:"attempt,omitempty"`
|
||||
// Started says the agent has looked at the module once since it started (its baseline): what runs was taken from its
|
||||
// have, or it was applied.
|
||||
Started bool `json:"started"`
|
||||
// Have is what the module's have key says now; nil when it says nothing.
|
||||
Have *HaveView `json:"have,omitempty"`
|
||||
// Composition is the revision read: the latest of the want's build, or the one whose digest was asked.
|
||||
Composition RevisionView `json:"composition"`
|
||||
// Revisions are the revisions of its key the bucket keeps, oldest first, each with its digest.
|
||||
Revisions []RevisionView `json:"revisions,omitempty"`
|
||||
// Runs is what runs of the module, and where that is known from; nil when nothing of it runs.
|
||||
Runs *RunsView `json:"runs,omitempty"`
|
||||
// Judged is the move from what runs to the composition read, as the agent judges it before an apply.
|
||||
Judged JudgedView `json:"judged"`
|
||||
// Diff is every resource that differs from what runs, when asked.
|
||||
Diff []judge.ResourceChange `json:"diff,omitempty"`
|
||||
// Own is, of the node's own composition (module node), its own fields beside what runs names (issue 774: ace's
|
||||
// own composition dropped the provisioner client's account, and nothing read could show it).
|
||||
Own *OwnView `json:"own,omitempty"`
|
||||
}
|
||||
|
||||
// OwnView is the node's own fields of a composition (engcomp.NodeOwn) beside what runs of them, and what the node's
|
||||
// composer held in its last pass.
|
||||
type OwnView struct {
|
||||
// Provisioner is the account the provisioner's client runs as, as the revision read names it; RunsProvisioner as
|
||||
// what runs names it. Empty: the declaration names none, and every filled write is refused.
|
||||
Provisioner string `json:"provisioner_account"`
|
||||
RunsProvisioner string `json:"runs_provisioner_account,omitempty"`
|
||||
// Adoption is the revision's adoption: the node adopted, and which of its modules are taken; nil for a converged node.
|
||||
Adoption *declaration.Adoption `json:"adoption,omitempty"`
|
||||
// Identities, Groups and Libraries count what the revision names of each.
|
||||
Identities int `json:"identities,omitempty"`
|
||||
Groups int `json:"groups,omitempty"`
|
||||
Libraries int `json:"libraries,omitempty"`
|
||||
// Held is each module the node's composer held in its last pass, with why.
|
||||
Held map[string]string `json:"held,omitempty"`
|
||||
}
|
||||
|
||||
// WantView is a want as the verb shows it: as the agent holds it, at the revision of its key it last read.
|
||||
type WantView struct {
|
||||
FP string `json:"fp"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Yes string `json:"yes,omitempty"`
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
// Self says the node composes the module itself (ADR 0547 §2).
|
||||
Self bool `json:"self,omitempty"`
|
||||
// Backup is the operator's answer to a failed backup the want carries, again or skip (ADR 0539 §16); empty when none.
|
||||
Backup string `json:"backup,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
// Revision is the revision of the want's key the agent read last.
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
|
||||
// AttemptView is the agent's record of a module's last apply that did not end running.
|
||||
type AttemptView struct {
|
||||
FP string `json:"fp"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
// WantRevision and CompositionRevision are the revisions of the want's and the composition's keys it was tried at.
|
||||
WantRevision uint64 `json:"want_revision"`
|
||||
CompositionRevision uint64 `json:"composition_revision"`
|
||||
// Held says it is held for a failed backup; AnsweredRevision is the want's revision the hold took, so an answer
|
||||
// (again or skip) in a later revision releases it.
|
||||
Held bool `json:"held,omitempty"`
|
||||
AnsweredRevision uint64 `json:"answered_revision,omitempty"`
|
||||
// Transient says it failed because the bus did not answer for a moment, and is tried again by the clock.
|
||||
Transient bool `json:"transient,omitempty"`
|
||||
}
|
||||
|
||||
// HaveView is a have as the verb shows it.
|
||||
type HaveView struct {
|
||||
FP string `json:"fp,omitempty"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Phase string `json:"phase"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Judgement string `json:"judgement,omitempty"`
|
||||
Waits string `json:"waits,omitempty"`
|
||||
}
|
||||
|
||||
// RevisionView is one revision of a composition key.
|
||||
type RevisionView struct {
|
||||
Key string `json:"key"`
|
||||
Revision uint64 `json:"revision,omitempty"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Resources int `json:"resources,omitempty"`
|
||||
Latest bool `json:"latest,omitempty"`
|
||||
// Refused is why the revision cannot be read or trusted.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// RunsView is what runs of a module.
|
||||
type RunsView struct {
|
||||
FP string `json:"fp,omitempty"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
// From is applied (what this engine applied), have (what its have named at the start) or kept (the declaration the
|
||||
// node kept before the switch).
|
||||
From string `json:"from"`
|
||||
Resources int `json:"resources"`
|
||||
}
|
||||
|
||||
// JudgedView is a move's judgement.
|
||||
type JudgedView struct {
|
||||
Judgement string `json:"judgement"`
|
||||
Words string `json:"words"`
|
||||
// WaitsForYes says the agent would wait for a person's yes before applying it: a module of policy record, a move that
|
||||
// recreates a container, moves data or cannot be judged, and no yes in its want for this digest.
|
||||
WaitsForYes bool `json:"waits_for_yes"`
|
||||
}
|
||||
|
||||
// Composition reads one module's composition beside what runs of it: the want's build (else the have's), its latest
|
||||
// revision or the one whose digest is given, and the move's judgement; with diff, the resources that differ.
|
||||
func (a *Agent) Composition(ctx context.Context, module, digest string, diff bool) (CompositionView, error) {
|
||||
if module == "" {
|
||||
return CompositionView{}, errors.New("name the module, or node for the node's own composition")
|
||||
}
|
||||
view := CompositionView{Node: a.env.Node, Module: module}
|
||||
var have *Have
|
||||
if a.env.Haves != nil {
|
||||
if h, ok := a.env.Haves.Read(ctx, module); ok {
|
||||
have = &h
|
||||
phase := h.Phase
|
||||
if phase == "" {
|
||||
phase = string(nodeengineclient.PhaseRunning)
|
||||
}
|
||||
view.Have = &HaveView{FP: h.FP, Composed: h.Composed, Phase: phase, Detail: h.Detail, Judgement: h.Judgement, Waits: h.Waits}
|
||||
}
|
||||
}
|
||||
a.mu.Lock()
|
||||
w := a.wants[module]
|
||||
fp := ""
|
||||
if w.Present {
|
||||
fp = w.Want.Fp
|
||||
view.Want = &WantView{FP: w.Want.Fp, Composed: w.Want.Composed, Yes: w.Want.Yes, Removed: w.Want.Removed,
|
||||
Self: w.Want.Self, Backup: w.Want.Backup, Words: w.Want.Words, Revision: w.Rev}
|
||||
} else if have != nil {
|
||||
fp = have.FP
|
||||
}
|
||||
if att, ok := a.attempt[module]; ok {
|
||||
view.Attempt = &AttemptView{FP: att.FP, Composed: att.Composed, WantRevision: att.WantRev, CompositionRevision: att.CompRev,
|
||||
Held: att.Held, AnsweredRevision: att.AnsweredRev, Transient: att.Transient}
|
||||
}
|
||||
view.Started = a.checked[module]
|
||||
latest, haveLatest := a.compositionFor(module, fp)
|
||||
r, ran := a.running[module]
|
||||
from, known := a.runningComposition(module)
|
||||
source := "applied"
|
||||
if _, ok := a.applied[module]; !ok {
|
||||
if _, ok := a.had[module]; ok {
|
||||
source = "have"
|
||||
} else {
|
||||
source = "kept"
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if module != NodeModule && fp == "" {
|
||||
return view, fmt.Errorf("%s has no want and no have on %s, so no build of it is composed here", module, a.env.Node)
|
||||
}
|
||||
key, err := a.compositionKey(module, fp)
|
||||
if err != nil {
|
||||
return view, err
|
||||
}
|
||||
revs, err := a.revisionsOf(ctx, key)
|
||||
if err != nil {
|
||||
return view, fmt.Errorf("the revisions of %s cannot be read: %w", key, err)
|
||||
}
|
||||
var read *compNow
|
||||
for i := range revs {
|
||||
c := revs[i]
|
||||
rv := RevisionView{Key: key, Revision: c.Rev, Digest: c.Dig, Resources: len(c.C.Resources),
|
||||
Latest: haveLatest && c.Rev == latest.Rev}
|
||||
if c.Err != nil {
|
||||
rv.Refused = c.Err.Error()
|
||||
}
|
||||
view.Revisions = append(view.Revisions, rv)
|
||||
if digest != "" && c.Err == nil && c.Dig == digest {
|
||||
read = &revs[i]
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case digest != "" && read == nil && haveLatest && latest.Err == nil && latest.Dig == digest:
|
||||
read = &latest
|
||||
case digest != "" && read == nil:
|
||||
return view, fmt.Errorf("no revision of %s the bucket keeps has the digest %s; revisions lists those it keeps", key, digest)
|
||||
case read == nil && !haveLatest:
|
||||
return view, fmt.Errorf("the bucket holds no composition of %s at %s", module, fp)
|
||||
case read == nil:
|
||||
read = &latest
|
||||
}
|
||||
view.Composition = RevisionView{Key: key, Revision: read.Rev, Digest: read.Dig, Resources: len(read.C.Resources),
|
||||
Latest: haveLatest && read.Rev == latest.Rev}
|
||||
if read.Err != nil {
|
||||
view.Composition.Refused = read.Err.Error()
|
||||
return view, nil
|
||||
}
|
||||
var running *engcomp.Composed
|
||||
if known {
|
||||
running = &from
|
||||
rv := &RunsView{From: source, Resources: len(from.Resources), FP: from.FP}
|
||||
if ran {
|
||||
rv.FP, rv.Composed, rv.Phase = r.Fp, r.Composed, string(r.Phase)
|
||||
}
|
||||
view.Runs = rv
|
||||
}
|
||||
if module == NodeModule {
|
||||
o := read.C.Own
|
||||
view.Own = &OwnView{Provisioner: o.Provisioner, Adoption: o.Adoption, Identities: len(o.Identities), Groups: len(o.Groups),
|
||||
Libraries: len(o.Libraries)}
|
||||
if running != nil {
|
||||
view.Own.RunsProvisioner = running.Own.Provisioner
|
||||
}
|
||||
if a.env.Held != nil {
|
||||
view.Own.Held = a.env.Held()
|
||||
}
|
||||
}
|
||||
j := judge.JudgeMove(running, read.C)
|
||||
view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words}
|
||||
if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) {
|
||||
switch j.Judgement {
|
||||
case nodeengineclient.JudgedRecreates, nodeengineclient.JudgedMovesData, nodeengineclient.JudgedCannot:
|
||||
view.Judged.WaitsForYes = a.env.Policy(ctx, module) == catalogue.PolicyRecord
|
||||
}
|
||||
}
|
||||
if diff {
|
||||
view.Diff = judge.ChangedResources(running, read.C)
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// compositionKey is a module's composition key at a build, or the node's own.
|
||||
func (a *Agent) compositionKey(module, fp string) (string, error) {
|
||||
if module == NodeModule {
|
||||
return engcomp.NodeKey(a.env.Node)
|
||||
}
|
||||
return engcomp.Key(a.env.Node, module, fp)
|
||||
}
|
||||
|
||||
// revisionsOf is every revision of a composition key the state keeps, oldest first, each with its digest (unsigned, ADR
|
||||
// 0593 §6), or why it is refused. Nil Revisions
|
||||
// reads none.
|
||||
func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) {
|
||||
if a.env.Revisions == nil {
|
||||
return nil, nil
|
||||
}
|
||||
revs, err := a.env.Revisions(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []compNow
|
||||
for _, u := range revs {
|
||||
if u.Deleted || u.Key != key {
|
||||
continue
|
||||
}
|
||||
c, err := engcomp.Decode(u.Value)
|
||||
if err != nil {
|
||||
out = append(out, compNow{Raw: u.Value, Rev: u.Revision, Err: err})
|
||||
continue
|
||||
}
|
||||
dig, err := engcomp.DigestOf(c) // unsigned: a record, never verified (ADR 0593 §6)
|
||||
out = append(out, compNow{Raw: u.Value, C: c, Dig: dig, Rev: u.Revision, Err: err})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
|
||||
// WriteAlone writes one file a module's composition declares, through the apply's own file step, and records it as an
|
||||
// apply would; nothing else of the module is applied, and nothing that reads the file is moved (novox/hq ADR 0547 §8).
|
||||
//
|
||||
// **For a file whose reader takes the change itself.** The bus's user list is the one it is for: the bus module's
|
||||
// entrypoint watches the file and signals the server to reload in place, so the list is in force without a want of the
|
||||
// bus, which nobody writes (ADR 0539 §15), and without the container being touched. A file that a service or a container
|
||||
// must be moved to read is not written alone: it goes through the module's apply, which moves what reads it.
|
||||
//
|
||||
// The file is written only when its content differs from what is there (the file step's own rule), read back, and
|
||||
// recorded under the module whose resource it is, so the module's next apply finds it as its own and writes nothing.
|
||||
// The caller holds the apply's lock. log is said the path, the action and the digest, never the content.
|
||||
func WriteAlone(ctx context.Context, sys system.System, file *declaration.File, known store.State, run Runner,
|
||||
log func(string)) (Outcome, store.State, error) {
|
||||
if log == nil {
|
||||
log = func(string) {}
|
||||
}
|
||||
if file.Into != "" || file.CreateOnce || file.Sealed != "" || file.VaultSealed != nil || len(file.Secrets) > 0 {
|
||||
return Outcome{}, known, &Error{Resource: file.ID, Err: fmt.Errorf("%s is not a whole file of plain content, "+
|
||||
"and only such a file is written alone; nothing was written", file.Path)}
|
||||
}
|
||||
if file.IsReference() {
|
||||
return Outcome{}, known, &Error{Resource: file.ID, Err: fmt.Errorf("%s is given by digest %s and was not "+
|
||||
"resolved to its content; it is not written empty", file.Path, file.Digest)}
|
||||
}
|
||||
if held, isHeld := known.HeldAt(file.ID); isHeld {
|
||||
return Outcome{}, known, &Error{Resource: file.ID, Err: fmt.Errorf("%s is held as found for a module not "+
|
||||
"yet taken (%s), so nothing was written", file.Path, held.Why)}
|
||||
}
|
||||
was, _ := known.Find(file.ID)
|
||||
if was.ID == "" {
|
||||
// Only over a file the mesh wrote: a file this node holds no record of is the module's apply's to take, with
|
||||
// the original kept, never this write's. A record of the path under another id (the declaration before the
|
||||
// switch named it so) is the one written to, so no second record of the file is left for an apply to remove.
|
||||
rec, ok := known.At(string(declaration.TypeFile), file.Path)
|
||||
if !ok {
|
||||
return Outcome{}, known, &Error{Resource: file.ID, Err: fmt.Errorf("this node holds no record of %s; it "+
|
||||
"is written by the module's apply first, never alone", file.Path)}
|
||||
}
|
||||
renamed := *file
|
||||
renamed.ID = rec.ID
|
||||
file, was = &renamed, rec
|
||||
}
|
||||
in := inputs{declares: map[string]string{file.Identity(): declaredDigest(file)}, known: &known,
|
||||
groups: groupsWanted([]declaration.Resource{file}), handed: Handed{}, dirsBefore: map[string]bool{},
|
||||
files: map[string]bool{file.Path: true}}
|
||||
outcome, err := applyOne(ctx, sys, file, run, map[string]bool{}, in, was, nil, nil)
|
||||
if err != nil {
|
||||
log(fmt.Sprintf(" failed %s (%s), alone: %v", file.ID, file.Path, err))
|
||||
return outcome, known, &Error{Resource: file.ID, Err: err}
|
||||
}
|
||||
recordKeeping(&known, file, outcome, was)
|
||||
if moved(outcome) {
|
||||
log(fmt.Sprintf(" %s %s (%s), alone: %s", outcome.Action, file.ID, file.Path, outcome.wrote))
|
||||
}
|
||||
return outcome, known, nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0547 §8: the bus node writes the bus's user list alone, with no want, through the apply's own
|
||||
// file step: only when it differs, mode 0600, read back and recorded as the module's, and nothing else moves.
|
||||
func TestAFileWrittenAloneMovesNothingElse(t *testing.T) {
|
||||
const was, now = "user a\n", "user a\nuser b\n"
|
||||
for _, c := range []struct {
|
||||
name, fields, before string
|
||||
recorded bool
|
||||
recordedAs string // the id the record has, when not the composition's
|
||||
action, refused string
|
||||
after string
|
||||
}{
|
||||
{name: "no record of it", fields: `"content":%q,"mode":"0600"`, before: was, refused: "holds no record", after: was},
|
||||
{name: "changed", fields: `"content":%q,"mode":"0600"`, before: was, recorded: true, action: "updated", after: now},
|
||||
{name: "the same", fields: `"content":%q,"mode":"0600"`, before: now, recorded: true, action: "unchanged", after: now},
|
||||
{name: "recorded under the id from before the switch", fields: `"content":%q,"mode":"0600"`, before: was,
|
||||
recorded: true, recordedAs: "bus-users", action: "updated", after: now},
|
||||
{name: "absent with a record", fields: `"content":%q,"mode":"0600"`, recorded: true, action: "created", after: now},
|
||||
{name: "written into", fields: `"content":%q,"into":"block","owner-at-runtime":"managed"`, before: was,
|
||||
recorded: true, refused: "only such a file", after: was},
|
||||
{name: "sealed", fields: `"sealed":"c2VhbGVk"`, before: was, recorded: true,
|
||||
refused: "only such a file", after: was},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "accounts.conf")
|
||||
if c.before != "" {
|
||||
if err := os.WriteFile(path, []byte(c.before), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fields := strings.Replace(c.fields, "%q", fmt.Sprintf("%q", now), 1)
|
||||
d := parseTrusted(t, fmt.Sprintf(`{"declaration":1,"resources":[{"id":"nats.bus-users","module":"nats",
|
||||
"type":"file","path":%q,`+fields+`}]}`, path))
|
||||
file := d.Resources[0].(*declaration.File)
|
||||
known := store.State{}
|
||||
id := file.ID
|
||||
if c.recordedAs != "" {
|
||||
id = c.recordedAs
|
||||
}
|
||||
if c.recorded {
|
||||
known.Record(store.Applied{ID: id, Type: string(declaration.TypeFile), Module: "nats", Target: path,
|
||||
Origin: store.OriginDeclared, Wrote: digestOf(c.before)})
|
||||
}
|
||||
calls := 0
|
||||
counting := func(context.Context, string, ...string) (string, error) { calls++; return "", nil }
|
||||
out, after, err := WriteAlone(context.Background(), archHost(t), file, known, counting, nil)
|
||||
if c.refused != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.refused) {
|
||||
t.Fatalf("not refused with %q: %v", c.refused, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
} else if out.Action != c.action {
|
||||
t.Errorf("action %q, want %q", out.Action, c.action)
|
||||
}
|
||||
if got := readText(t, path); got != c.after {
|
||||
t.Errorf("file holds %q, want %q", got, c.after)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Errorf("%d commands were run: a file written alone moves nothing", calls)
|
||||
}
|
||||
if c.refused != "" {
|
||||
return
|
||||
}
|
||||
if m := modeAt(t, path); m != 0o600 {
|
||||
t.Errorf("mode %o", m)
|
||||
}
|
||||
rec, ok := after.Find(id)
|
||||
if !ok || rec.Wrote != digestOf(now) || rec.Target != path {
|
||||
t.Errorf("record %+v (found %v), want it to say %s was written", rec, ok, digestOf(now))
|
||||
}
|
||||
if n := len(after.Resources); n != 1 {
|
||||
t.Errorf("%d records of one file", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+455
-51
@@ -21,7 +21,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -300,6 +299,103 @@ func ApplyMindingWindows(
|
||||
unseal Unseal,
|
||||
keep Keep,
|
||||
windows *Windows,
|
||||
) (Report, store.State, error) {
|
||||
return applyScoped(ctx, sys, d, nil, known, origin, run, log, unseal, keep, windows)
|
||||
}
|
||||
|
||||
// Scope is what makes an apply one module's (novox/hq ADR 0429 §4, ADR 0539 §9): the module whose want changed, and
|
||||
// the resources the node's other modules hold, so a path, a unit or a group another module still asks for is neither
|
||||
// removed nor taken from it. Only that module's own records are orphans; a removed module is a Scope with an empty
|
||||
// declaration.
|
||||
type Scope struct {
|
||||
// Module is the module applied. NodeModule (store.NodeModule) is the node's own composition.
|
||||
Module string
|
||||
// Others are the resources of every other module the node has a composition for.
|
||||
Others []declaration.Resource
|
||||
// Placed are the modules a want still places on the node (present, not removed), held ones included: no apply but
|
||||
// the module's own removes a resource of one of them (issue 774: on 2026-10-29 the node's own apply on the bus node,
|
||||
// composed without what its folded modules ran, removed the bus server and four held modules). Nil guards nothing.
|
||||
Placed map[string]bool
|
||||
// RemovedByWant are the modules whose want says removed. In the node's own apply (Module NodeModule) with this set
|
||||
// non-nil, a resource of any other module is removed only when that module is here: a module the node runs folded
|
||||
// in its own composition has no want, so Placed does not name it (issue 774, review of the re-land, round 1: nats on
|
||||
// the bus node has none, and its nats.conf was guessed as nats's and removed). Nil guards by Placed alone.
|
||||
RemovedByWant map[string]bool
|
||||
}
|
||||
|
||||
// placedOwner is the module a want still places on the node that the record belongs to, other than the scope's own:
|
||||
// the module recorded with it, else the one its id names (a resource the node's own composition carried for a folded
|
||||
// module is recorded as the node's, its id naming the module). Empty when there is none.
|
||||
func (sc *Scope) placedOwner(a store.Applied) string {
|
||||
if sc == nil || (len(sc.Placed) == 0 && sc.RemovedByWant == nil) {
|
||||
return ""
|
||||
}
|
||||
m := a.Module
|
||||
if m == "" || m == store.NodeModule {
|
||||
m, _ = declaration.ModuleGuess(a.ID)
|
||||
}
|
||||
if m == "" || m == sc.Module || m == store.NodeModule {
|
||||
return ""
|
||||
}
|
||||
if sc.Placed[m] {
|
||||
return m
|
||||
}
|
||||
if sc.Module == store.NodeModule && sc.RemovedByWant != nil && !sc.RemovedByWant[m] {
|
||||
return m // a module the node's own composition carried, which no want removed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// owns reports whether a record belongs to the scope's module.
|
||||
//
|
||||
// In a module's own apply, a record the node's own composition made for the module while it was folded (recorded as the
|
||||
// node's, its id naming the module) is the module's too: it is the only apply that may remove it once neither
|
||||
// composition declares it (issue 774). One the node's own still declares stays, as another's held resource does.
|
||||
func (sc *Scope) owns(a store.Applied) bool {
|
||||
if store.OwnerOf(a, declaration.ModuleGuess) == sc.Module {
|
||||
return true
|
||||
}
|
||||
if sc.Module == store.NodeModule || a.Module != store.NodeModule {
|
||||
return false
|
||||
}
|
||||
m, ok := declaration.ModuleGuess(a.ID)
|
||||
return ok && m == sc.Module
|
||||
}
|
||||
|
||||
// isNode is whether the scope is the node's own composition, the one that carries identities, groups, libraries and
|
||||
// the adoption.
|
||||
func (sc *Scope) isNode() bool { return sc == nil || sc.Module == store.NodeModule }
|
||||
|
||||
// ApplyModule makes the machine match one module's declaration, and only that module's: everything else the node
|
||||
// runs is left as it is. d holds the module's resources; scope names the module and what the others hold.
|
||||
func ApplyModule(
|
||||
ctx context.Context,
|
||||
sys system.System,
|
||||
d *declaration.Declaration,
|
||||
scope Scope,
|
||||
known store.State,
|
||||
origin string,
|
||||
run Runner,
|
||||
log func(string),
|
||||
unseal Unseal,
|
||||
keep Keep,
|
||||
windows *Windows,
|
||||
) (Report, store.State, error) {
|
||||
return applyScoped(ctx, sys, d, &scope, known, origin, run, log, unseal, keep, windows)
|
||||
}
|
||||
|
||||
func applyScoped(
|
||||
ctx context.Context,
|
||||
sys system.System,
|
||||
d *declaration.Declaration,
|
||||
scope *Scope,
|
||||
known store.State,
|
||||
origin string,
|
||||
run Runner,
|
||||
log func(string),
|
||||
unseal Unseal,
|
||||
keep Keep,
|
||||
windows *Windows,
|
||||
) (report Report, _ store.State, _ error) {
|
||||
// Said whichever way the apply ended, failure included: a window being open is a fact about the
|
||||
// machine, not about this apply.
|
||||
@@ -326,7 +422,9 @@ func ApplyMindingWindows(
|
||||
}
|
||||
// What an unfinished apply found a unit as is kept only while its service is declared: one
|
||||
// declared again later is read afresh, as any resource with no record is (novox/hq ADR 0118).
|
||||
known.DropFoundUndeclared(declared, origin)
|
||||
if scope == nil {
|
||||
known.DropFoundUndeclared(declared, origin)
|
||||
}
|
||||
|
||||
// Which units the mesh made, read before any removal can take a unit file's record away — so
|
||||
// whichever order a module declared a unit and its file in, the unit is known for the mesh's
|
||||
@@ -351,7 +449,11 @@ func ApplyMindingWindows(
|
||||
// it back would stop or disable a unit the declaration says is running, every container on it
|
||||
// with it, only for the declared one to start it again in the same apply. The record is
|
||||
// forgotten; the unit's found state is the remaining record's to give back.
|
||||
heldUnits := unitsHeld(d.Resources)
|
||||
everything := d.Resources
|
||||
if scope != nil {
|
||||
everything = append(append([]declaration.Resource{}, d.Resources...), scope.Others...)
|
||||
}
|
||||
heldUnits := unitsHeld(everything)
|
||||
|
||||
// **A whole file still declared at its path by another resource is handed to it, never removed
|
||||
// first** (novox/hq ADR 0223). A file can change owners between modules — the machine's resolver
|
||||
@@ -361,20 +463,116 @@ func ApplyMindingWindows(
|
||||
// and stayed so until the new resource's turn came, with every resource in between applied on a
|
||||
// machine without it. The record going is kept until the one declaring the path now is recorded
|
||||
// in its place: then it is forgotten, and what it kept of the original goes with the file.
|
||||
heldFiles := filesHeld(d.Resources)
|
||||
heldFiles := filesHeld(everything)
|
||||
handedFiles := map[string]store.Applied{}
|
||||
|
||||
// **A group still asked for by another resource is not given back when one record of it goes**
|
||||
// (novox/hq ADR 0252), on the unit's rule above: two modules may each want the account in one group,
|
||||
// and the one going takes nothing the other still needs.
|
||||
wanted := groupsWanted(d.Resources)
|
||||
wanted := groupsWanted(everything)
|
||||
// And what a record going hands to a resource that still asks for its group (novox/hq ADR 0252 §3).
|
||||
handed := Handed{}
|
||||
|
||||
// **A resource another module's composition now holds is that module's, and is left** (issue 1945): a module that
|
||||
// leaves the node's own composition for one of its own takes its resources with it, by their identity, and the node's
|
||||
// own apply leaves each as it is, record and all, until that module's apply takes it. Removed, a node lost its
|
||||
// resolver guard, its sudoers file and a running container between two applies.
|
||||
heldByOthers := map[string]string{}
|
||||
if scope != nil {
|
||||
for _, r := range scope.Others {
|
||||
heldByOthers[r.Identity()] = r.DeclaredModule()
|
||||
}
|
||||
}
|
||||
|
||||
// **A resource the node's own carried for a module that now has a composition of its own is that module's to remove**
|
||||
// (issue 774, 2026-10-30 on the bus node): recorded as the node's while the module was folded, it is no longer in the
|
||||
// node's own composition and no longer in the module's own either (restic's measure, replaced by awake). The node's own
|
||||
// apply leaves it, record and all, and says so; the module's own apply sees it as its own (Scope.owns) and removes it.
|
||||
// Refused instead, the node's own applied nothing at all, every two minutes, for as long as the record stayed.
|
||||
takenByItsModule := map[string]string{}
|
||||
if scope != nil && scope.Module == store.NodeModule {
|
||||
composes := map[string]bool{}
|
||||
for _, r := range scope.Others {
|
||||
if m := r.DeclaredModule(); m != "" && m != store.NodeModule {
|
||||
composes[m] = true
|
||||
}
|
||||
}
|
||||
for _, orphan := range known.Orphans(declared, origin) {
|
||||
if orphan.Module != store.NodeModule {
|
||||
continue
|
||||
}
|
||||
if m, ok := declaration.ModuleGuess(orphan.ID); ok && composes[m] {
|
||||
takenByItsModule[orphan.ID] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A module a want still places here is never removed by another's apply** (issue 774): its resources go only with
|
||||
// its own apply or its want's removal. One that would is refused whole, before anything is changed, naming them: a
|
||||
// composition that leaves them out lost what runs, and removing what runs is the worst a node can do with that.
|
||||
if scope != nil {
|
||||
var refused []string
|
||||
for _, orphan := range known.Orphans(declared, origin) {
|
||||
if !scope.owns(orphan) {
|
||||
continue
|
||||
}
|
||||
if _, kept := heldByOthers[orphan.ID]; kept {
|
||||
continue
|
||||
}
|
||||
if _, kept := takenByItsModule[orphan.ID]; kept {
|
||||
continue
|
||||
}
|
||||
if m := scope.placedOwner(orphan); m != "" {
|
||||
refused = append(refused, orphan.ID+" of "+m)
|
||||
}
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
sort.Strings(refused)
|
||||
err := fmt.Errorf("the apply of %s would remove %s, of modules a want still places here; a module's resources "+
|
||||
"go only with its own apply or its want's removal, so nothing was applied", scope.Module, strings.Join(refused, ", "))
|
||||
log(" refused: " + err.Error())
|
||||
return report, known, &Error{Resource: "the resources of modules placed here", Err: err, Done: report}
|
||||
}
|
||||
}
|
||||
|
||||
removeOrphan := func(orphan store.Applied) error {
|
||||
var action, detail string
|
||||
var err error
|
||||
if module, held := heldByOthers[orphan.ID]; held && !declared[orphan.ID] {
|
||||
if module == "" {
|
||||
module = "another module"
|
||||
}
|
||||
detail := "no longer declared here; the composition of " + module + " holds it, so it was left as it is"
|
||||
report.Outcomes = append(report.Outcomes, Outcome{
|
||||
ID: orphan.ID, Type: orphan.Type, Target: orphan.Target,
|
||||
Action: "kept", Detail: detail,
|
||||
})
|
||||
log(fmt.Sprintf(" kept %s (%s): %s", orphan.ID, orphan.Target, detail))
|
||||
return nil
|
||||
}
|
||||
if module, taken := takenByItsModule[orphan.ID]; taken {
|
||||
detail := "no longer declared here; " + module + " has a composition of its own here, whose apply removes it, " +
|
||||
"so it was left as it is"
|
||||
report.Outcomes = append(report.Outcomes, Outcome{
|
||||
ID: orphan.ID, Type: orphan.Type, Target: orphan.Target,
|
||||
Action: "kept", Detail: detail,
|
||||
})
|
||||
log(fmt.Sprintf(" kept %s (%s): %s", orphan.ID, orphan.Target, detail))
|
||||
return nil
|
||||
}
|
||||
if by, held := heldFiles[fileKey(orphan)]; held && by != orphan.ID {
|
||||
if scope != nil && !declared[by] {
|
||||
// Another module declares the path: it is that module's, so this record is let go and the file is
|
||||
// neither removed nor taken from it (ADR 0539 §9).
|
||||
known.Forget(orphan.ID)
|
||||
detail := "no longer declared; " + by + " of another module still holds the path, so it was left as it is"
|
||||
report.Outcomes = append(report.Outcomes, Outcome{
|
||||
ID: orphan.ID, Type: orphan.Type, Target: orphan.Target,
|
||||
Action: "forgotten", Detail: detail,
|
||||
})
|
||||
log(fmt.Sprintf(" forgotten %s (%s): %s", orphan.ID, orphan.Target, detail))
|
||||
return nil
|
||||
}
|
||||
handedFiles[by] = orphan
|
||||
return nil
|
||||
}
|
||||
@@ -412,7 +610,7 @@ func ApplyMindingWindows(
|
||||
err = fmt.Errorf("%w: %s", errRemovalWaits, said)
|
||||
}
|
||||
default:
|
||||
action, detail, err = remove(ctx, sys, orphan, run, made)
|
||||
action, detail, err = remove(ctx, sys, orphan, run, made, journalDir(windows))
|
||||
}
|
||||
if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) {
|
||||
// **A former target of a kind the host cannot remove is left in place and forgotten,
|
||||
@@ -482,20 +680,11 @@ func ApplyMindingWindows(
|
||||
}
|
||||
handover := map[string][]store.Applied{}
|
||||
for _, orphan := range known.Orphans(declared, origin) {
|
||||
if d.Adoption == nil && strings.HasPrefix(orphan.ID, declaration.AdoptionPrefix) {
|
||||
protecting = append(protecting, orphan)
|
||||
if scope != nil && !scope.owns(orphan) {
|
||||
continue
|
||||
}
|
||||
// **A module the mesh left out is not a module the mesh removed** (novox/hq ADR 0163, rule
|
||||
// 6): its resources are absent because a setting stored for it cannot compose, and the
|
||||
// mesh said so by name. What the host wrote for it stays as it is, recorded, until the
|
||||
// module is declared again or unassigned.
|
||||
if module, left := d.LeftOutModuleOf(orphan.ID); left {
|
||||
report.Outcomes = append(report.Outcomes, Outcome{
|
||||
ID: orphan.ID, Type: orphan.Type, Target: orphan.Target,
|
||||
Action: "unchanged", Detail: "kept: " + module + " was left out of this declaration by the mesh, not removed",
|
||||
})
|
||||
log(fmt.Sprintf(" kept %s (%s): %s was left out of this declaration by the mesh, not removed", orphan.ID, orphan.Target, module))
|
||||
if d.Adoption == nil && strings.HasPrefix(orphan.ID, declaration.AdoptionPrefix) {
|
||||
protecting = append(protecting, orphan)
|
||||
continue
|
||||
}
|
||||
if by, replaced := replacedBy[orphan.ID]; replaced {
|
||||
@@ -538,12 +727,7 @@ func ApplyMindingWindows(
|
||||
// what is assigned: a carried bundle's silence is not an unassignment.
|
||||
if origin == store.OriginDeclared {
|
||||
for _, h := range append([]store.Held{}, known.Held...) {
|
||||
if declared[h.ID] {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(d.LeftOut, h.Module) {
|
||||
// Left out, not unassigned (ADR 0163, rule 6): still held for the module, as the
|
||||
// mesh asked.
|
||||
if declared[h.ID] || (scope != nil && h.Module != scope.Module) {
|
||||
continue
|
||||
}
|
||||
known.Release(h.ID)
|
||||
@@ -745,7 +929,7 @@ func ApplyMindingWindows(
|
||||
in.held = heldRoots(verdicts, d.Libraries)
|
||||
// A tool runner no longer put in a group leaves it before anything is applied, so its unit, which names the
|
||||
// change under restart-on, is restarted in this same apply to lose it (ADR 0328 §11, ADR 0334 §2, ADR 0252 §3).
|
||||
if origin == store.OriginDeclared {
|
||||
if origin == store.OriginDeclared && scope.isNode() {
|
||||
for _, o := range leaveRunners(ctx, sys, d.Libraries, d.Identities, &known, run, wanted) {
|
||||
if o.Action == "restored" {
|
||||
changed[o.changes] = true
|
||||
@@ -755,6 +939,13 @@ func ApplyMindingWindows(
|
||||
}
|
||||
}
|
||||
setRunnerWarnings(warnings)
|
||||
// **Every new image of a module is on the machine before any of its containers is set aside** (design 51, issue 1966
|
||||
// ENG-7). A container replaced after the first one's turn pulled its image only then, and for a module whose first
|
||||
// container serves the store its second image comes from, that pull met the stopped one. A pull that fails
|
||||
// replaces nothing of the module: its containers keep running what they ran, the failure names the image, and what
|
||||
// follows in the module is skipped as it is for any step that did not complete.
|
||||
in.pulled = map[string]bool{}
|
||||
pullFirst(ctx, ordered, d, in.pulled, run, &failures, gated, gatedWhy, log)
|
||||
for i, resource := range ordered {
|
||||
if !orphansRemoved && i == guardFirst {
|
||||
// **Only a guard that is up may let the filter go.** Removing the derived filter's
|
||||
@@ -1037,6 +1228,11 @@ func ApplyMindingWindows(
|
||||
}
|
||||
// Only now. The record follows the fact, never leads it.
|
||||
recordApplied(&known, origin, resource, outcome, kept, keptMode, keptOwner)
|
||||
if scope != nil {
|
||||
known.SetModule(resource.Identity(), scope.Module)
|
||||
} else if module, ours := d.ModuleOfID(resource.Identity()); ours {
|
||||
known.SetModule(resource.Identity(), module)
|
||||
}
|
||||
appliedOK[resource.Identity()] = true
|
||||
// Its module has been taken, and what was held for it is now the mesh's. A file written
|
||||
// into, or a service whose lifecycle is the machine's, replaced nothing that was found, so
|
||||
@@ -1138,8 +1334,21 @@ func ApplyMindingWindows(
|
||||
}
|
||||
}
|
||||
|
||||
// A file written or removed in a unit directory is read by the service manager only after a reload, whether or not
|
||||
// a service reflects it: a drop-in on reboot.target is reflected by no service, and without the reload the next
|
||||
// reboot still runs the target as it was loaded (issue 1126). Once per apply.
|
||||
if at := unitFileMoved(report.Outcomes); at != "" {
|
||||
if u, ok := sys.(unitReloader); ok {
|
||||
if err := u.ReloadUnits(ctx, run); err != nil {
|
||||
failures = append(failures, &Error{Resource: at, Err: fmt.Errorf("reloading the service manager's units: %w", err)})
|
||||
} else {
|
||||
log(fmt.Sprintf(" reloaded the service manager's units: %s changed", at))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the mesh says what is assigned: a carried bundle's silence undeclares no identity and no folder.
|
||||
if origin == store.OriginDeclared {
|
||||
if origin == store.OriginDeclared && scope.isNode() {
|
||||
// And an identity, then a library group, no longer declared goes, once what ran as it went above (ADR 0328
|
||||
// §3, §4).
|
||||
if tool, err := toolFor(sys); err == nil {
|
||||
@@ -1158,7 +1367,7 @@ func ApplyMindingWindows(
|
||||
// the mesh's derived filter among it — applied cleanly (novox/hq ADR 0100), and on every
|
||||
// converged apply, not once (ADR 0168). Skipped, it is said: a step that does nothing is never
|
||||
// silent (issue 143).
|
||||
if len(failures) == 0 {
|
||||
if len(failures) == 0 && scope.isNode() {
|
||||
did, err := retireFirewall(ctx, d, origin, &known, run, log)
|
||||
if err != nil {
|
||||
return report, known, &Error{Resource: "the firewall found on this machine", Err: err, Done: report}
|
||||
@@ -1169,7 +1378,7 @@ func ApplyMindingWindows(
|
||||
return report, known, err
|
||||
}
|
||||
}
|
||||
} else if rec := known.Firewall; origin == store.OriginDeclared && d.Adoption == nil && rec != nil &&
|
||||
} else if rec := known.Firewall; len(failures) > 0 && scope.isNode() && origin == store.OriginDeclared && d.Adoption == nil && rec != nil &&
|
||||
rec.Kind == string(firewall.UFW) && rec.WasActive && firewall.Active(ctx, run) {
|
||||
report.Firewall = fmt.Sprintf("left in force: %d resource(s) failed, and the found firewall is retired only after a clean apply", len(failures))
|
||||
log(" kept ufw in force: " + report.Firewall)
|
||||
@@ -1370,7 +1579,7 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
|
||||
case *declaration.User:
|
||||
return applyUser(ctx, sys, res, run, previous, in.groups, in.handed)
|
||||
case *declaration.Archive:
|
||||
return applyArchive(ctx, res, previous, in.away)
|
||||
return applyArchive(ctx, res, previous, in.away, journalDir(in.windows))
|
||||
case *declaration.Process:
|
||||
return applyProcess(ctx, res, run, changed, previous, in.away)
|
||||
case *declaration.Action:
|
||||
@@ -1912,11 +2121,68 @@ type serviceReloader interface {
|
||||
ReloadService(ctx context.Context, run system.Runner, unit string) error
|
||||
}
|
||||
|
||||
// UnitDirs are the system service manager's unit directories: a file the apply writes or removes in one of them, or in a
|
||||
// drop-in directory below one, is read by the manager only after a reload (issue 1126). Replaceable in tests.
|
||||
var UnitDirs = []string{"/etc/systemd/system", "/run/systemd/system", "/usr/local/lib/systemd/system"}
|
||||
|
||||
// unitFileMoved is the first file this apply wrote or removed in a unit directory or a drop-in directory below one,
|
||||
// or "" when it moved none.
|
||||
func unitFileMoved(outcomes []Outcome) string {
|
||||
for _, o := range outcomes {
|
||||
// Only what changed the file on disk, as MovesRoot reads it: skipped, held, kept, forgotten and waiting come
|
||||
// back on every apply and change nothing.
|
||||
if declaration.Type(o.Type) != declaration.TypeFile {
|
||||
continue
|
||||
}
|
||||
switch o.Action {
|
||||
case "created", "updated", "corrected", "removed":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
at := filepath.Clean(o.Target)
|
||||
for _, dir := range UnitDirs {
|
||||
if rel, err := filepath.Rel(dir, at); err == nil && rel != "." && !strings.HasPrefix(rel, "..") {
|
||||
return at
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// unitReloader is a service manager that caches unit files and must be told to read them again.
|
||||
type unitReloader interface {
|
||||
ReloadUnits(ctx context.Context, run system.Runner) error
|
||||
}
|
||||
|
||||
// restReader is a service manager that can say whether a one-shot has nothing running now
|
||||
// (novox/hq issue 1126).
|
||||
type restReader interface {
|
||||
ServiceAtRest(ctx context.Context, run system.Runner, unit string) (bool, error)
|
||||
}
|
||||
|
||||
// declaredState is a unit's state as a declaration of want reads it. **A one-shot at rest is
|
||||
// stopped to a declaration that it be stopped** (novox/hq issue 1126): a one-shot that does not
|
||||
// linger reads "running" once it ran and worked — that is what a declaration that it run asks — but
|
||||
// it has no process between its runs, so it is also as stopped as it can be, and stopping it changes
|
||||
// nothing. Read only as "running", a boot-time cleanup declared stopped and enabled failed every
|
||||
// apply after the boot that ran it: "was asked to be stopped and is running".
|
||||
func declaredState(ctx context.Context, sys system.System, run Runner, unit, want string) (string, error) {
|
||||
state, err := sys.ServiceState(ctx, run, unit)
|
||||
if err != nil || want != "stopped" || state != "running" {
|
||||
return state, err
|
||||
}
|
||||
if r, ok := sys.(restReader); ok {
|
||||
rest, err := r.ServiceAtRest(ctx, run, unit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rest {
|
||||
return "stopped", nil
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// serviceSettle is how long the host waits before looking at a unit a second time. A test sets it
|
||||
// to nothing; on a machine it is the window in which a daemon that refuses its configuration dies.
|
||||
var serviceSettle = 2 * time.Second
|
||||
@@ -2069,7 +2335,7 @@ func applyServiceIn(ctx context.Context, sys system.System, r *declaration.Servi
|
||||
// own stopping from anybody else's (novox/hq ADR 0269).
|
||||
out.declared = &store.FoundUnit{Unit: r.Unit, State: r.State, Boot: r.Boot}
|
||||
|
||||
before, err := sys.ServiceState(ctx, run, r.Unit)
|
||||
before, err := declaredState(ctx, sys, run, r.Unit, r.State)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -2080,7 +2346,7 @@ func applyServiceIn(ctx context.Context, sys system.System, r *declaration.Servi
|
||||
|
||||
// Read back. A service manager accepting a command says the transaction was accepted,
|
||||
// not that the unit is running — one that starts and immediately dies satisfies it.
|
||||
after, err := sys.ServiceState(ctx, run, r.Unit)
|
||||
after, err := declaredState(ctx, sys, run, r.Unit, r.State)
|
||||
if err == nil && r.State == "running" {
|
||||
after, err = stayedRunning(ctx, sys, run, r.Unit)
|
||||
}
|
||||
@@ -2224,7 +2490,7 @@ func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service,
|
||||
//
|
||||
// made is the units whose unit file this host wrote where there was none (meshMadeUnits).
|
||||
func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
||||
made map[string]bool) (string, string, error) {
|
||||
made map[string]bool, journals string) (string, string, error) {
|
||||
switch declaration.Type(a.Type) {
|
||||
case declaration.TypeDirectory, declaration.TypeFile, declaration.TypeArchive:
|
||||
// Removal below a home follows no link an account put there either (novox/hq ADR 0266).
|
||||
@@ -2350,7 +2616,7 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
||||
case declaration.TypeArchive:
|
||||
// Exactly what it unpacked, and the directories the host made for it once they are empty
|
||||
// (novox/hq issue 162).
|
||||
return removeArchive(a)
|
||||
return removeArchive(a, journals)
|
||||
|
||||
default:
|
||||
return "", "", fmt.Errorf("%w: a %q", errNoRemoval, a.Type)
|
||||
@@ -2379,12 +2645,18 @@ func ExecRunner(ctx context.Context, name string, args ...string) (string, error
|
||||
if err != nil {
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
// The words as they always were, and the exit underneath them, so a caller asks the
|
||||
// code (system.ExitCode) rather than matching text that this line is free to reword.
|
||||
return string(out), &exited{
|
||||
words: fmt.Sprintf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(string(exit.Stderr))),
|
||||
exit: exit,
|
||||
words := fmt.Sprintf("%s exited %d: %s", name, exit.ExitCode(), strings.TrimSpace(string(exit.Stderr)))
|
||||
if exit.ExitCode() == -1 {
|
||||
// Killed, not exited: say by what, and the context's end when that is what killed it (issue 1945:
|
||||
// "docker exited -1:" said nothing while a bus container was half replaced).
|
||||
words = fmt.Sprintf("%s was %s", name, exit.String())
|
||||
if ctx.Err() != nil {
|
||||
words += " because its context ended: " + ctx.Err().Error()
|
||||
}
|
||||
}
|
||||
// The words, and the exit underneath them, so a caller asks the code (system.ExitCode) rather than
|
||||
// matching text that this line is free to reword.
|
||||
return string(out), &exited{words: words, exit: exit}
|
||||
}
|
||||
return string(out), fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
@@ -2494,6 +2766,9 @@ type inputs struct {
|
||||
files map[string]bool
|
||||
// handed is every group a record that went this apply handed to a resource still asking for it.
|
||||
handed Handed
|
||||
// pulled is every image this apply found on the machine, or pulled, before any container was replaced (pullFirst):
|
||||
// applyContainer does not ask for it again.
|
||||
pulled map[string]bool
|
||||
}
|
||||
|
||||
// fileDigest is what a file the container reads holds, by digest.
|
||||
@@ -2873,6 +3148,14 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
aside := ""
|
||||
if !existed {
|
||||
// A container an engine set aside and then stopped before it ran the replacement (it died between the two):
|
||||
// the set-aside one of this apply, put back if the replacement fails and removed once it runs (issue 1945).
|
||||
if _, err := containerState(ctx, r.Name+asideSuffix, run); err == nil {
|
||||
aside = r.Name + asideSuffix
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case existed && (before.Spec == want || legacy) && before.Running && len(reasons) == 0:
|
||||
out.Action = "unchanged"
|
||||
@@ -2892,18 +3175,45 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner,
|
||||
// gone: an image the registry does not hold then left the service down and every later apply
|
||||
// failing. Pulled (or found present) first, an unobtainable image fails this resource loudly,
|
||||
// naming the image, and the old container keeps running.
|
||||
if err := ensureImage(ctx, cri, r.Image, run); err != nil {
|
||||
return out, fmt.Errorf("not replacing container %s, which keeps running: %w", r.Name, err)
|
||||
if !in.pulled[r.Image] {
|
||||
if err := ensureImage(ctx, cri, r.Image, run); err != nil {
|
||||
return out, fmt.Errorf("not replacing container %s, which keeps running: %w", r.Name, err)
|
||||
}
|
||||
}
|
||||
if _, err := run(ctx, cri, "rm", "-f", r.Name); err != nil {
|
||||
return out, fmt.Errorf("replacing container %s: %w", r.Name, err)
|
||||
// **The running container is set aside, never removed, until its replacement runs** (issue 1945): stopped and
|
||||
// renamed, so its name and ports are free, and started again under its own name when the replacement does not
|
||||
// come up. Removed first, the bus's own container left a node without a bus when its replacement was never
|
||||
// made (2026-10-25 at 11:23). A container set aside by an earlier apply that stopped midway goes first.
|
||||
aside = r.Name + asideSuffix
|
||||
_, _ = run(ctx, cri, "rm", "-f", aside)
|
||||
if _, err := run(ctx, cri, "rename", r.Name, aside); err != nil {
|
||||
return out, fmt.Errorf("replacing container %s: setting the running one aside: %w", r.Name, err)
|
||||
}
|
||||
if _, err := run(ctx, cri, "stop", aside); err != nil {
|
||||
_, _ = run(ctx, cri, "rename", aside, r.Name)
|
||||
return out, fmt.Errorf("replacing container %s: stopping the running one: %w", r.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// putBack starts the container set aside again under its own name, after a replacement that did not come up.
|
||||
putBack := func(cause error) error {
|
||||
if aside == "" {
|
||||
return cause
|
||||
}
|
||||
_, _ = run(ctx, cri, "rm", "-f", r.Name)
|
||||
if _, err := run(ctx, cri, "rename", aside, r.Name); err != nil {
|
||||
return fmt.Errorf("%w; and the container it replaced could not be put back: %v", cause, err)
|
||||
}
|
||||
if _, err := run(ctx, cri, "start", r.Name); err != nil {
|
||||
return fmt.Errorf("%w; and the container it replaced could not be started again: %v", cause, err)
|
||||
}
|
||||
return fmt.Errorf("%w; the container it replaced was started again", cause)
|
||||
}
|
||||
|
||||
args := detachedRunArgs(r, want)
|
||||
|
||||
if _, err := run(ctx, cri, args...); err != nil {
|
||||
return out, fmt.Errorf("starting container %s: %w", r.Name, err)
|
||||
return out, putBack(fmt.Errorf("starting container %s: %w", r.Name, err))
|
||||
}
|
||||
|
||||
// Read back. `docker run --detach` returning an id says the container was created, not
|
||||
@@ -2911,15 +3221,21 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner,
|
||||
// command exactly as one that came up does.
|
||||
after, err := containerState(ctx, r.Name, run)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("started container %s and it is not there: %w", r.Name, err)
|
||||
return out, putBack(fmt.Errorf("started container %s and it is not there: %w", r.Name, err))
|
||||
}
|
||||
if !after.Running {
|
||||
return out, fmt.Errorf(
|
||||
return out, putBack(fmt.Errorf(
|
||||
"container %s was started and is not running. It exited; ask the runtime for its "+
|
||||
"logs", r.Name)
|
||||
"logs", r.Name))
|
||||
}
|
||||
if after.Spec != want {
|
||||
return out, fmt.Errorf("container %s is not the one that was declared after creating it", r.Name)
|
||||
return out, putBack(fmt.Errorf("container %s is not the one that was declared after creating it", r.Name))
|
||||
}
|
||||
// Only now, with the replacement running, is the container set aside removed.
|
||||
if aside != "" {
|
||||
if _, err := run(ctx, cri, "rm", "-f", aside); err != nil {
|
||||
out.Detail = fmt.Sprintf("the container it replaced, set aside as %s, could not be removed: %v", aside, err)
|
||||
}
|
||||
}
|
||||
// The found networks a per-machine setting keeps for it (novox/hq ADR 0163, rule 4), joined
|
||||
// once it runs: a runtime starts a container on one network, and the others are connected.
|
||||
@@ -2955,6 +3271,9 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// asideSuffix names a running container set aside while its replacement is started (issue 1945).
|
||||
const asideSuffix = ".replaced"
|
||||
|
||||
// detachedRunArgs is the `docker run` that starts a service container and leaves it running.
|
||||
func detachedRunArgs(r *declaration.Container, want string) []string {
|
||||
args := []string{"run", "--detach", "--name", r.Name, "--restart", "unless-stopped"}
|
||||
@@ -3162,16 +3481,17 @@ func ensureImage(ctx context.Context, cri, image string, run Runner) error {
|
||||
// already hold — built here, or handed over. There is no registry that answers for it, so
|
||||
// pulling would fail somewhere that names a network problem instead of a missing image.
|
||||
if strings.HasPrefix(image, "sha256:") {
|
||||
return fmt.Errorf(
|
||||
return &PullError{Image: image, Started: time.Now(), Err: fmt.Errorf(
|
||||
"image %s is not on this machine, and an image named by its own digest cannot be "+
|
||||
"fetched: nothing serves it. Build it here, or load it, before applying this",
|
||||
image)
|
||||
image)}
|
||||
}
|
||||
started := time.Now()
|
||||
if _, err := run(ctx, cri, "pull", image); err != nil {
|
||||
return fmt.Errorf("pulling image %s: %w", image, err)
|
||||
return &PullError{Image: image, Started: started, Err: fmt.Errorf("pulling image %s: %w", image, err)}
|
||||
}
|
||||
if _, err := run(ctx, cri, "image", "inspect", image); err != nil {
|
||||
return fmt.Errorf("image %s is not present after pulling it: %w", image, err)
|
||||
return &PullError{Image: image, Started: time.Now(), Err: fmt.Errorf("image %s is not present after pulling it: %w", image, err)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -4033,3 +4353,87 @@ func ownerText(info os.FileInfo) string {
|
||||
}
|
||||
return fmt.Sprintf("%d:%d", uid, gid)
|
||||
}
|
||||
|
||||
// pullFirst finds on the machine, or pulls, the image of each container the declaration runs before any is replaced
|
||||
// (ENG-7) for a module of two containers or more. A container that is run once is left to its marker, and a runtime that
|
||||
// does not answer to applyContainer, which says so. The first image that cannot be had fails its container with the image named and gates its module, so none of
|
||||
// its containers is touched; the images found before it stay pulled.
|
||||
func pullFirst(ctx context.Context, ordered []declaration.Resource, d *declaration.Declaration, pulled map[string]bool,
|
||||
run Runner, failures *[]*Error, gated map[string]bool, gatedWhy map[string]string, log func(string)) {
|
||||
// A module of one container already pulls its image before it sets that container aside (applyContainer), and nothing
|
||||
// of it comes before: only a module of two or more has an image pulled after one of its containers was replaced.
|
||||
count := map[string]int{}
|
||||
for _, resource := range ordered {
|
||||
if c, isContainer := resource.(*declaration.Container); isContainer && c.Image != "" && !c.RunOnce && c.Schedule == "" {
|
||||
module, _ := d.ModuleOfID(c.Identity())
|
||||
count[module]++
|
||||
}
|
||||
}
|
||||
var cri string
|
||||
for _, resource := range ordered {
|
||||
c, isContainer := resource.(*declaration.Container)
|
||||
if !isContainer || c.Image == "" || c.RunOnce || c.Schedule != "" || pulled[c.Image] {
|
||||
continue
|
||||
}
|
||||
module, ours := d.ModuleOfID(c.Identity())
|
||||
if count[module] < 2 {
|
||||
continue
|
||||
}
|
||||
if ours && gated[module] {
|
||||
continue
|
||||
}
|
||||
if cri == "" {
|
||||
var err error
|
||||
if cri, err = containerRuntime(ctx, run); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := ensureImage(ctx, cri, c.Image, run); err != nil {
|
||||
err = fmt.Errorf("not replacing container %s, or any other container of its module, which keep running: %w", c.Name, err)
|
||||
*failures = append(*failures, &Error{Resource: c.Identity(), Err: err})
|
||||
log(fmt.Sprintf(" failed %s (%s): %v", c.Identity(), c.Target(), err))
|
||||
if ours {
|
||||
gated[module] = true
|
||||
gatedWhy[module] = "its image " + c.Image + " could not be had, so none of its containers was replaced"
|
||||
}
|
||||
continue
|
||||
}
|
||||
pulled[c.Image] = true
|
||||
}
|
||||
}
|
||||
|
||||
// PullError is an image that could not be found or pulled, as ensureImage fails: what the node agent reads to tell a failed
|
||||
// pull from another failed apply (ENG-8).
|
||||
type PullError struct {
|
||||
Image string
|
||||
// Started is when the pull began, which the node agent compares with when a store's holder settled (ENG-11).
|
||||
Started time.Time
|
||||
Err error
|
||||
}
|
||||
|
||||
// PullErrors are the pull errors of every failure an apply reports, the first among them and the rest in Failures, in the
|
||||
// order they happened: what the node agent tells a failed pull from another failed apply by (ENG-8). Nil for an error
|
||||
// that is no apply's, or whose failures met none.
|
||||
func PullErrors(err error) []*PullError {
|
||||
var out []*PullError
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
for _, f := range e.Failures {
|
||||
var p *PullError
|
||||
if errors.As(f.Err, &p) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
var p *PullError
|
||||
if errors.As(err, &p) {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *PullError) Error() string { return e.Err.Error() }
|
||||
func (e *PullError) Unwrap() error { return e.Err }
|
||||
|
||||
@@ -350,6 +350,42 @@ func TestAnUnknownServiceStateIsRefusedNotGuessed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A boot-time one-shot a package ships, declared stopped and enabled at boot, is satisfied while
|
||||
// nothing of it runs, whatever its last run did (novox/hq issue 1126). The kernel module's
|
||||
// linux-modules-cleanup.service failed on ace with "was asked to be stopped and is running": an
|
||||
// inactive one-shot with ExecMainStatus=0 reads "running", and stopping it changes nothing.
|
||||
func TestABootOneShotDeclaredStoppedIsSatisfiedAtRest(t *testing.T) {
|
||||
for _, c := range []struct{ name, status string }{
|
||||
{"exited 0: never ran, ran at boot, or unloaded", "0"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var asked []string
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
args = oldShape(name, args)
|
||||
switch args[0] {
|
||||
case "show":
|
||||
return "LoadState=loaded\nActiveState=inactive\nType=oneshot\nRemainAfterExit=no\n" +
|
||||
"ExecMainStatus=" + c.status + "\n", nil
|
||||
case "is-enabled":
|
||||
return "enabled\n", nil
|
||||
}
|
||||
asked = append(asked, strings.Join(args, " "))
|
||||
return "", nil
|
||||
}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"cleanup","type":"service","unit":"linux-modules-cleanup.service","state":"stopped","boot":"enabled"}
|
||||
]}`)
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a one-shot at rest declared stopped failed: %v", err)
|
||||
}
|
||||
if len(asked) > 0 {
|
||||
t.Errorf("the service manager was asked %q for a unit already as declared", asked)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestADroppedServiceIsGivenBackTheStateItWasFoundIn(t *testing.T) {
|
||||
// novox/hq ADR 0118: undeclaring removes what the mesh made, gives back what it changed, and
|
||||
// leaves what was the machine's. A service resource never installs a unit — so undeclaring it
|
||||
@@ -1225,6 +1261,52 @@ func TestAServiceIsRestartedWhenWhatItReflectsChanges(t *testing.T) {
|
||||
if reloaded < 0 || reloaded > stop {
|
||||
t.Errorf("the service was restarted without the unit files being read again first; commands were %v", commands)
|
||||
}
|
||||
|
||||
// A drop-in in a unit directory that no service reflects — the power module's bound on reboot.target
|
||||
// (issue 1126) — is read by the service manager only after a reload: one per apply, and no restart.
|
||||
// Unchanged, it reloads nothing.
|
||||
units := filepath.Join(dir, "system")
|
||||
saved := UnitDirs
|
||||
UnitDirs = []string{units}
|
||||
t.Cleanup(func() { UnitDirs = saved })
|
||||
dropIn := filepath.Join(units, "reboot.target.d", "mesh-power.conf")
|
||||
withDropIn := parse(t, fmt.Sprintf(`{"declaration":1,"resources":[
|
||||
{"id":"conf","type":"file","path":%q,"content":"second\n","mode":"0644"},
|
||||
{"id":"svc","type":"service","unit":"thing.service","state":"running","restart-on":["conf"]},
|
||||
{"id":"units","type":"directory","path":%q,"mode":"0755"},
|
||||
{"id":"drop-ins","type":"directory","path":%q,"mode":"0755"},
|
||||
{"id":"bound","type":"file","path":%q,"content":"[Unit]\nJobTimeoutSec=10min\n","mode":"0644"},
|
||||
{"id":"bound-too","type":"file","path":%q,"content":"[Unit]\nJobTimeoutSec=10min\n","mode":"0644"}
|
||||
]}`, path, units, filepath.Dir(dropIn), dropIn, filepath.Join(filepath.Dir(dropIn), "other.conf")))
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
reloads int
|
||||
}{{"a drop-in written", 1}, {"the same drop-in again", 0}} {
|
||||
commands = nil
|
||||
if _, state, err = Apply(context.Background(), archHost(t), withDropIn, state,
|
||||
store.OriginCarried, run, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloads := 0
|
||||
for _, c := range commands {
|
||||
if strings.Contains(c, "daemon-reload") {
|
||||
reloads++
|
||||
}
|
||||
if strings.Contains(c, "stop thing.service") {
|
||||
t.Errorf("%s: a drop-in no service reflects restarted one: %v", row.name, commands)
|
||||
}
|
||||
}
|
||||
if reloads != row.reloads {
|
||||
t.Errorf("%s: %d reloads, want %d; commands were %v", row.name, reloads, row.reloads, commands)
|
||||
}
|
||||
}
|
||||
// A drop-in whose module is skipped behind its step, or held as found, comes back so on every apply: it moved
|
||||
// nothing, and reloads nothing.
|
||||
for _, action := range []string{"skipped", "held"} {
|
||||
if at := unitFileMoved([]Outcome{{ID: "bound", Type: "file", Target: dropIn, Action: action}}); at != "" {
|
||||
t.Errorf("a drop-in %s reloads the service manager (%s)", action, at)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,14 @@ import (
|
||||
// enough for a desktop theme and small enough to notice.
|
||||
const maxArchive = 512 << 20
|
||||
|
||||
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway) (Outcome, error) {
|
||||
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway, journals string) (Outcome, error) {
|
||||
out := begin(r)
|
||||
out.Action = "unchanged"
|
||||
recorded := previous.Wrote != ""
|
||||
previous, err := withJournal(journals, previous, r.Path)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
body, err := fetchMinding(ctx, r.Source, away)
|
||||
if err != nil {
|
||||
@@ -78,8 +83,12 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
|
||||
return out, err
|
||||
}
|
||||
out.unpacked = &unpacked
|
||||
// At once, before the rest of the apply can fail or the engine stop (novox/hq issue 1585).
|
||||
if err := keepJournal(journals, r.Path, got, &unpacked); err != nil {
|
||||
return out, fmt.Errorf("%s was unpacked, and what it unpacked could not be written down: %w", r.Path, err)
|
||||
}
|
||||
out.Action = "updated"
|
||||
if previous.Wrote == "" {
|
||||
if !recorded {
|
||||
out.Action = "created"
|
||||
}
|
||||
out.Detail = fmt.Sprintf("%d file(s)", written)
|
||||
@@ -290,3 +299,7 @@ func unpack(body []byte, into string) (int, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch is an archive at source as the host fetches every archive it unpacks, at most maxArchive bytes: what the node's
|
||||
// own composer reads a moved module's templates archive with (issue 1126). Its digest is the caller's to check.
|
||||
func Fetch(ctx context.Context, source string) ([]byte, error) { return fetch(ctx, source) }
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// What an archive unpacked is written down the moment it is unpacked (novox/hq issue 1585).
|
||||
//
|
||||
// The node's state is saved at the end of a module's apply, so an engine that stopped, was replaced or lost its
|
||||
// state between the unpack and that save held files on the machine it had no record of. The next apply then found
|
||||
// its own files and refused them as ones "the mesh did not put there" (issue 162), and a module could not move to
|
||||
// its new build until a person removed them. So, at once and atomically, the host keeps the digest it unpacked and
|
||||
// the files and directories it placed; an apply that holds no record of the archive reads this instead of treating
|
||||
// everything there as somebody's. The record in the state stays the authority: the journal is read only when there
|
||||
// is none.
|
||||
//
|
||||
// **In the node-engine's own state directory, readable and writable by root alone, never beside the archive.** The
|
||||
// archive's directory may be writable by the module's account (a home, a data directory): a journal there could be
|
||||
// forged, and a root engine would then remove, or take as its own, any path the forger named. And an entry is read
|
||||
// only when it names a path inside the archive's directory (filepath.IsLocal), and a parent only when it is above it.
|
||||
|
||||
// JournalDirName is the directory under the node's state directory that keeps the journals.
|
||||
const JournalDirName = "unpacked"
|
||||
|
||||
type unpackJournal struct {
|
||||
Target string `json:"target"`
|
||||
Wrote string `json:"wrote"`
|
||||
Unpacked *store.Unpacked `json:"unpacked"`
|
||||
}
|
||||
|
||||
// journalDir is where the journals of this apply are kept: under the state directory every applier on the machine
|
||||
// shares (the one its windows are kept in). Empty for a caller with no state directory (a test), which keeps none.
|
||||
func journalDir(w *Windows) string {
|
||||
if w == nil || w.dir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(filepath.Dir(w.dir), JournalDirName)
|
||||
}
|
||||
|
||||
// journalPath names the journal of the archive at target: by the digest of its path, so no path names a file
|
||||
// outside the journals' directory.
|
||||
func journalPath(dir, target string) string {
|
||||
sum := sha256.Sum256([]byte(filepath.Clean(target)))
|
||||
return filepath.Join(dir, hex.EncodeToString(sum[:])+".json")
|
||||
}
|
||||
|
||||
// keepJournal writes down what an archive unpacked at path.
|
||||
func keepJournal(dir, path, wrote string, u *store.Unpacked) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(unpackJournal{Target: filepath.Clean(path), Wrote: wrote, Unpacked: u})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomicallyByPath(journalPath(dir, path), raw, 0o600)
|
||||
}
|
||||
|
||||
// journalOf is what the journal says of path: false when there is none, and an error when there is one that cannot
|
||||
// be read or names a path outside the archive's directory, which is never believed.
|
||||
func journalOf(dir, path string) (unpackJournal, bool, error) {
|
||||
if dir == "" {
|
||||
return unpackJournal{}, false, nil
|
||||
}
|
||||
raw, err := os.ReadFile(journalPath(dir, path))
|
||||
if err != nil {
|
||||
return unpackJournal{}, false, nil
|
||||
}
|
||||
var j unpackJournal
|
||||
if err := json.Unmarshal(raw, &j); err != nil {
|
||||
return unpackJournal{}, false, fmt.Errorf("the journal of what was unpacked at %s is unreadable: %w", path, err)
|
||||
}
|
||||
if j.Wrote == "" || j.Unpacked == nil {
|
||||
return unpackJournal{}, false, fmt.Errorf("the journal of what was unpacked at %s names no digest or files", path)
|
||||
}
|
||||
if err := checkJournal(j, filepath.Clean(path)); err != nil {
|
||||
return unpackJournal{}, false, err
|
||||
}
|
||||
return j, true, nil
|
||||
}
|
||||
|
||||
// checkJournal refuses a journal of another target, an entry that is not a path inside the archive's directory, and
|
||||
// a parent that is not above it.
|
||||
func checkJournal(j unpackJournal, target string) error {
|
||||
if j.Target != target {
|
||||
return fmt.Errorf("the journal kept for %s names %q", target, j.Target)
|
||||
}
|
||||
for _, rel := range append(append([]string(nil), j.Unpacked.Files...), j.Unpacked.Dirs...) {
|
||||
if !filepath.IsLocal(filepath.FromSlash(rel)) {
|
||||
return fmt.Errorf("the journal of what was unpacked at %s names %q, which is not inside it: not believed", target, rel)
|
||||
}
|
||||
}
|
||||
for _, p := range j.Unpacked.Parents {
|
||||
if p = filepath.Clean(p); !filepath.IsAbs(p) || p == target || !strings.HasPrefix(target, p+string(filepath.Separator)) {
|
||||
return fmt.Errorf("the journal of what was unpacked at %s names the parent %q, which is not above it: not believed", target, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withJournal is previous, or, when the state holds no record of the archive, what the journal says the host
|
||||
// unpacked at path. A journal not believed is an error: the apply stops and says so rather than guess.
|
||||
func withJournal(dir string, previous store.Applied, path string) (store.Applied, error) {
|
||||
if previous.Wrote != "" {
|
||||
return previous, nil
|
||||
}
|
||||
j, ok, err := journalOf(dir, path)
|
||||
if err != nil || !ok {
|
||||
return previous, err
|
||||
}
|
||||
previous.Wrote, previous.Target, previous.Unpacked = j.Wrote, path, j.Unpacked
|
||||
return previous, nil
|
||||
}
|
||||
|
||||
// dropJournal removes the journal of the archive at target, once the archive is taken off; whether the state's
|
||||
// record of it still said what it unpacked or not.
|
||||
func dropJournal(dir, target string) {
|
||||
if dir == "" || target == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(journalPath(dir, target))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -152,6 +153,98 @@ func TestAnArchiveDoesNotWriteOverAFileItFound(t *testing.T) {
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
|
||||
t.Fatalf("%d entries beside the directory", len(entries))
|
||||
}
|
||||
|
||||
// Issue 1585: what the host unpacked is written down at once, in its own state directory, so an archive whose
|
||||
// record in the state was lost is still the host's: the next version replaces its files, drops the ones it no
|
||||
// longer has and leaves a file somebody added. A file the host never unpacked still refuses it, and so does a
|
||||
// journal naming a path outside the archive's directory. The journal goes with the archive, record lost or not.
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
journaled bool // the first version was unpacked by the host
|
||||
foreign string
|
||||
forged func(j *unpackJournal)
|
||||
refused string
|
||||
}{
|
||||
{"unpacked by the host, record lost", true, "", nil, ""},
|
||||
{"unpacked by the host, somebody's file beside", true, "mine.txt", nil, ""},
|
||||
{"never unpacked by the host", false, "", nil, "did not put there"},
|
||||
{"a journal naming a path outside", true, "", func(j *unpackJournal) {
|
||||
j.Unpacked.Files = append(j.Unpacked.Files, "../../etc/passwd")
|
||||
}, "not inside it"},
|
||||
{"a journal naming a parent not above", true, "", func(j *unpackJournal) {
|
||||
j.Unpacked.Parents = []string{"/etc"}
|
||||
}, "not above it"},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
dir, state := t.TempDir(), t.TempDir()
|
||||
windows, journals := WindowsIn(state), filepath.Join(state, JournalDirName)
|
||||
applyIt := func(decl string, known store.State) (store.State, error) {
|
||||
_, after, err := ApplyMindingWindows(context.Background(), archHost(t), declare(t, decl), known,
|
||||
store.OriginDeclared, noServices, nil, nil, nil, windows)
|
||||
return after, err
|
||||
}
|
||||
target := filepath.Join(dir, "tools")
|
||||
first, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "one", "old": "o"}, "")
|
||||
if row.journaled {
|
||||
if _, err := applyIt(first, store.State{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
|
||||
t.Fatalf("%d entries beside the archive: the journal is the state directory's", len(entries))
|
||||
}
|
||||
info, err := os.Stat(journalPath(journals, target))
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("no journal of root's alone after the unpack: %v %v", info, err)
|
||||
}
|
||||
} else {
|
||||
os.MkdirAll(target, 0o755)
|
||||
os.WriteFile(filepath.Join(target, "bin"), []byte("one"), 0o644)
|
||||
}
|
||||
if row.forged != nil {
|
||||
j, _, err := journalOf(journals, target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row.forged(&j)
|
||||
raw, _ := json.Marshal(j)
|
||||
os.WriteFile(journalPath(journals, target), raw, 0o600)
|
||||
}
|
||||
if row.foreign != "" {
|
||||
os.WriteFile(filepath.Join(target, row.foreign), []byte("x"), 0o644)
|
||||
}
|
||||
second, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "two"}, "")
|
||||
// The state is empty: the record of the first unpack was lost.
|
||||
after, err := applyIt(second, store.State{})
|
||||
if row.refused != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), row.refused) {
|
||||
t.Fatalf("refused? %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(target, "bin")); string(got) != "two" {
|
||||
t.Fatalf("bin is %q", got)
|
||||
}
|
||||
if exists(filepath.Join(target, "old")) {
|
||||
t.Fatal("a file the new version no longer has is still there")
|
||||
}
|
||||
if row.foreign != "" && !exists(filepath.Join(target, row.foreign)) {
|
||||
t.Fatal("somebody's file went")
|
||||
}
|
||||
// Taken off, with a record that no longer says what it unpacked: the journal goes too.
|
||||
for i := range after.Resources {
|
||||
after.Resources[i].Unpacked = nil
|
||||
}
|
||||
if _, err := applyIt(`{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`, after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(journalPath(journals, target)) {
|
||||
t.Fatal("the journal outlived the archive")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestADirectoryTheHostMadeGoesOnlyWhenEmpty(t *testing.T) {
|
||||
|
||||
@@ -364,7 +364,16 @@ func mergeInto(fresh, path, owner string, files, dirs []string, o ours) (store.U
|
||||
// module's tools from its bundle — sees the whole tree or none of it, never half, and a file it has
|
||||
// open stays readable until it closes it. The runtime is told the module went by its own membership,
|
||||
// not by the files disappearing.
|
||||
func removeArchive(a store.Applied) (string, string, error) {
|
||||
func removeArchive(a store.Applied, journals string) (string, string, error) {
|
||||
action, detail, err := removeArchiveFiles(a)
|
||||
// And its journal (issue 1585), once the record goes: whether the record said what it unpacked or not.
|
||||
if err == nil {
|
||||
dropJournal(journals, a.Target)
|
||||
}
|
||||
return action, detail, err
|
||||
}
|
||||
|
||||
func removeArchiveFiles(a store.Applied) (string, string, error) {
|
||||
if store.IsFormer(a.ID) {
|
||||
// The version before is what a rollback starts (ADR 0141) and what a reader may still have
|
||||
// open; the launcher retires the host's own versions, not the apply (issue 194).
|
||||
|
||||
@@ -33,6 +33,7 @@ func newBusyMachine() *aBusyMachine {
|
||||
}
|
||||
|
||||
func (m *aBusyMachine) run(_ context.Context, _ string, args ...string) (string, error) {
|
||||
args = asideShape(args)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
switch args[0] {
|
||||
|
||||
@@ -20,6 +20,9 @@ func TestAContainerJoinsTheNetworksItKeeps(t *testing.T) {
|
||||
if name != "docker" {
|
||||
return "", errors.New("not installed")
|
||||
}
|
||||
if len(args) > 0 && args[0] == "container" && strings.HasSuffix(args[len(args)-1], asideSuffix) {
|
||||
return "", errors.New("no such container") // nothing was left set aside
|
||||
}
|
||||
ran = append(ran, strings.Join(args, " "))
|
||||
switch args[0] {
|
||||
case "info":
|
||||
@@ -75,69 +78,6 @@ func TestAContainerJoinsTheNetworksItKeeps(t *testing.T) {
|
||||
|
||||
var specOfLast string
|
||||
|
||||
// A module the mesh left out of a declaration is not a module the mesh removed (novox/hq ADR 0163,
|
||||
// rule 6): what the host wrote for it stays, recorded and said; what it holds for it stays held.
|
||||
// A module simply absent is removed as it always was.
|
||||
func TestALeftOutModuleIsNeitherRemovedNorForgotten(t *testing.T) {
|
||||
var removed []string
|
||||
gone := map[string]bool{}
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
args = oldShape(name, args)
|
||||
if name != "docker" {
|
||||
return "", nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "29.0.0\n", nil
|
||||
case "rm":
|
||||
removed = append(removed, args[len(args)-1])
|
||||
gone[args[len(args)-1]] = true
|
||||
case "container":
|
||||
if gone[args[len(args)-1]] {
|
||||
return "", errors.New("no such container")
|
||||
}
|
||||
return "true\tspec", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
known := store.State{
|
||||
Resources: []store.Applied{
|
||||
{ID: "web.server", Type: "container", Target: "web", Origin: store.OriginDeclared},
|
||||
{ID: "old.server", Type: "container", Target: "old", Origin: store.OriginDeclared},
|
||||
},
|
||||
Held: []store.Held{{ID: "web.page", Module: "web", Kind: "file", Target: "/srv/web/index.html"}},
|
||||
}
|
||||
d := parse(t, `{"declaration":1,"left_out":["web"],"resources":[
|
||||
{"id":"notes.conf","type":"file","path":"`+t.TempDir()+`/notes.conf","content":"x"}
|
||||
]}`)
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(removed) != 1 || removed[0] != "old" {
|
||||
t.Fatalf("removed %v; only the module that is absent goes", removed)
|
||||
}
|
||||
if _, kept := state.At("container", "web"); !kept {
|
||||
t.Fatal("the left-out module's record was forgotten")
|
||||
}
|
||||
if _, held := state.HeldAt("web.page"); !held {
|
||||
t.Fatal("the left-out module's hold was released")
|
||||
}
|
||||
said := false
|
||||
for _, o := range report.Outcomes {
|
||||
if o.ID == "web.server" && o.Action == "unchanged" && strings.Contains(o.Detail, "web was left out of this declaration by the mesh") {
|
||||
said = true
|
||||
}
|
||||
if o.ID == "web.server" && o.Action != "unchanged" {
|
||||
t.Errorf("the left-out module's container was %s", o.Action)
|
||||
}
|
||||
}
|
||||
if !said {
|
||||
t.Fatalf("keeping the left-out module's container was not said: %+v", report.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
// A container's capabilities reach the runtime and are part of its spec (novox/hq ADR 0170).
|
||||
func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
@@ -0,0 +1,19 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A command killed because its context ended says so, and why: "docker exited -1:" said nothing while novox's bus
|
||||
// container was half replaced (issue 1945, live on 2026-10-25 at 11:23).
|
||||
func TestACommandKilledByItsContextSaysWhy(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(50 * time.Millisecond); cancel() }()
|
||||
_, err := ExecRunner(ctx, "sleep", "5")
|
||||
if err == nil || !strings.Contains(err.Error(), "killed") || !strings.Contains(err.Error(), "context canceled") {
|
||||
t.Fatalf("a command its context killed said: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,30 @@ func WriteFilled(
|
||||
log func(string),
|
||||
keep Keep,
|
||||
windows *Windows,
|
||||
) (report Report, _ store.State, _ error) {
|
||||
return WriteFilledWith(ctx, sys, d, known, path, content, run, log, keep, windows, Filled{})
|
||||
}
|
||||
|
||||
// Filled is what the node-engine did to a filled file's content before its write.
|
||||
type Filled struct {
|
||||
// Secret says the node-engine filled a value into the content, a provision's `${secret:}` (novox/hq ADR 0570
|
||||
// clause 6): the file is then written 0600 when the declaration gives no mode, as a file carrying a secret is.
|
||||
Secret bool
|
||||
}
|
||||
|
||||
// WriteFilledWith is WriteFilled for content the node-engine filled before the write (Filled).
|
||||
func WriteFilledWith(
|
||||
ctx context.Context,
|
||||
sys system.System,
|
||||
d *declaration.Declaration,
|
||||
known store.State,
|
||||
path string,
|
||||
content []byte,
|
||||
run Runner,
|
||||
log func(string),
|
||||
keep Keep,
|
||||
windows *Windows,
|
||||
filled Filled,
|
||||
) (report Report, _ store.State, _ error) {
|
||||
if log == nil {
|
||||
log = func(string) {}
|
||||
@@ -65,7 +89,7 @@ func WriteFilled(
|
||||
return report, known, &Error{Resource: path, Err: fmt.Errorf("%d resources declare %s, and a filled file "+
|
||||
"is written for exactly one, so nothing was written", len(declared), path)}
|
||||
}
|
||||
file := filledAs(declared[0], content)
|
||||
file := filledAs(declared[0], content, filled.Secret)
|
||||
if held, isHeld := known.HeldAt(file.ID); isHeld {
|
||||
return report, known, &Error{Resource: file.ID, Err: fmt.Errorf("%s is held as found for a module not "+
|
||||
"yet taken (%s), so nothing was written", path, held.Why)}
|
||||
@@ -163,10 +187,10 @@ func WriteFilled(
|
||||
}
|
||||
|
||||
// filledAs is the declared file with the filled content in place of the declaration's.
|
||||
func filledAs(declared *declaration.File, content []byte) *declaration.File {
|
||||
func filledAs(declared *declaration.File, content []byte, secret bool) *declaration.File {
|
||||
f := *declared
|
||||
f.Content, f.Sealed, f.VaultSealed, f.Bytes, f.Secrets = string(content), "", nil, "", nil
|
||||
if f.Mode == "" && (declared.Secret() || len(declared.Secrets) > 0) {
|
||||
if f.Mode == "" && (secret || declared.Secret() || len(declared.Secrets) > 0) {
|
||||
// Written as the declaration would have written it: a file carrying a credential is not world-readable.
|
||||
f.Mode = "0600"
|
||||
}
|
||||
|
||||
@@ -52,6 +52,24 @@ func TestAWriteOfAPathNotFilledIsRefused(t *testing.T) {
|
||||
if _, err := os.Stat(other); err == nil {
|
||||
t.Errorf("%s was written", other)
|
||||
}
|
||||
t.Run("a filled path two resources declare", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
if err := os.WriteFile(path, []byte(workstationHosts), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := parseTrusted(t, fmt.Sprintf(`{"declaration":1,"filled_files":[%q],"resources":[
|
||||
{"id":"a.hosts","type":"file","path":%q,"into":"block","content":"10.42.0.1 ace\n","owner-at-runtime":"managed"},
|
||||
{"id":"b.hosts","type":"file","path":%q,"into":"block","content":"10.42.0.2 g14\n","owner-at-runtime":"managed"}
|
||||
]}`, path, path, path))
|
||||
_, state, err := WriteFilled(context.Background(), archHost(t), d, store.State{}, path, []byte("6.6.6.6 evil\n"),
|
||||
noServices, nil, nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "2 resources declare") {
|
||||
t.Fatalf("a path two resources declare was not refused: %v", err)
|
||||
}
|
||||
if got := readText(t, path); got != workstationHosts || len(state.Resources) != 0 {
|
||||
t.Errorf("something was written or recorded: %q %+v", got, state.Resources)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Each ownership is written by the write verb exactly as an apply of the declared file with the same content: the
|
||||
@@ -112,6 +130,17 @@ func TestAFilledFileIsWrittenAsItsOwnershipSays(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("a filled file that carried a secret is not world-readable", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "token.env")
|
||||
d := filledDecl(t, path, `"content":"TOKEN=${secret:token}\n","secrets":{"token":"c2VhbGVk"},"owner-at-runtime":"owned"`, "")
|
||||
if _, _, err := WriteFilled(context.Background(), archHost(t), d, store.State{}, path, []byte("TOKEN=abc\n"),
|
||||
noServices, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := modeAt(t, path); m != 0o600 {
|
||||
t.Errorf("a filled file that carried a secret is mode %o", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func contentOf(t *testing.T, fields string) string {
|
||||
@@ -136,17 +165,6 @@ func modeAt(t *testing.T, path string) os.FileMode {
|
||||
|
||||
// A file that carried a secret keeps the mode a secret is written with: the client filled the value, and the file
|
||||
// is no more readable for it.
|
||||
func TestAFilledFileThatCarriedASecretIsNotWorldReadable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "token.env")
|
||||
d := filledDecl(t, path, `"content":"TOKEN=${secret:token}\n","secrets":{"token":"c2VhbGVk"},"owner-at-runtime":"owned"`, "")
|
||||
if _, _, err := WriteFilled(context.Background(), archHost(t), d, store.State{}, path, []byte("TOKEN=abc\n"),
|
||||
noServices, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := modeAt(t, path); m != 0o600 {
|
||||
t.Errorf("a filled file that carried a secret is mode %o", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWriteUnderReloadOnReloadsItsProgramOnce(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daemon.conf")
|
||||
@@ -251,21 +269,3 @@ func count(lines []string, prefix string) int {
|
||||
|
||||
// Two resources declaring one filled path — two modules' blocks in one file — are refused: the request carries the
|
||||
// content only, so it cannot say whose block it is, and nothing is written.
|
||||
func TestAFilledPathTwoResourcesDeclareIsRefused(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
if err := os.WriteFile(path, []byte(workstationHosts), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := parseTrusted(t, fmt.Sprintf(`{"declaration":1,"filled_files":[%q],"resources":[
|
||||
{"id":"a.hosts","type":"file","path":%q,"into":"block","content":"10.42.0.1 ace\n","owner-at-runtime":"managed"},
|
||||
{"id":"b.hosts","type":"file","path":%q,"into":"block","content":"10.42.0.2 g14\n","owner-at-runtime":"managed"}
|
||||
]}`, path, path, path))
|
||||
_, state, err := WriteFilled(context.Background(), archHost(t), d, store.State{}, path, []byte("6.6.6.6 evil\n"),
|
||||
noServices, nil, nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "2 resources declare") {
|
||||
t.Fatalf("a path two resources declare was not refused: %v", err)
|
||||
}
|
||||
if got := readText(t, path); got != workstationHosts || len(state.Resources) != 0 {
|
||||
t.Errorf("something was written or recorded: %q %+v", got, state.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,10 @@ type fakeContainer struct {
|
||||
// options. The host passes `--` before every unit (third review of mesh-catalog #147), which
|
||||
// internal/system's own test holds; the fakes read the unit where they always did.
|
||||
func oldShape(name string, args []string) []string {
|
||||
if name != "systemctl" || len(args) < 2 || args[len(args)-2] != "--" {
|
||||
if name != "systemctl" {
|
||||
return asideShape(args)
|
||||
}
|
||||
if len(args) < 2 || args[len(args)-2] != "--" {
|
||||
return args
|
||||
}
|
||||
v := 0
|
||||
@@ -1128,3 +1131,16 @@ func TestAPathTheMeshOnlySetAccessOnIsStillFound(t *testing.T) {
|
||||
t.Errorf("it was re-moded to %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// asideShape reads the steps of a container set aside while its replacement starts (issue 1945) as the removal the
|
||||
// tests were written against: setting it aside is its removal, and what is done to the set-aside container afterwards
|
||||
// is no step of theirs.
|
||||
func asideShape(args []string) []string {
|
||||
if len(args) == 3 && args[0] == "rename" && args[2] == args[1]+asideSuffix {
|
||||
return []string{"rm", "-f", args[1]}
|
||||
}
|
||||
if len(args) > 0 && strings.HasSuffix(args[len(args)-1], asideSuffix) && (args[0] == "rm" || args[0] == "stop") {
|
||||
return []string{"aside"}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ func TestRemovalThroughALinkUnderAHomeIsRefused(t *testing.T) {
|
||||
home, outside := aLinkedHome(t)
|
||||
link(t, outside, filepath.Join(home, ".claude"))
|
||||
_, _, err := remove(context.Background(), archHost(t), store.Applied{ID: "m.f", Type: "file",
|
||||
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil)
|
||||
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil, "")
|
||||
mustBeLinkRefusal(t, err)
|
||||
if _, err := os.Stat(filepath.Join(outside, "shadow")); err != nil {
|
||||
t.Fatal("removed through the link")
|
||||
|
||||
@@ -270,6 +270,7 @@ func newRegistryMachine(spec string) *aRegistryMachine {
|
||||
}
|
||||
|
||||
func (m *aRegistryMachine) run(_ context.Context, _ string, args ...string) (string, error) {
|
||||
args = asideShape(args)
|
||||
m.mu.Lock()
|
||||
switch args[0] {
|
||||
case "info", "image":
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// R6-A AC3: given two modules declaring one path, when one module's want changes, the shared path is neither removed
|
||||
// nor taken from the other module (novox/hq ADR 0539 §9).
|
||||
func TestASharedPathIsNeitherRemovedNorTakenFromTheOtherModule(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
shared := filepath.Join(dir, "shared.conf")
|
||||
own := filepath.Join(dir, "a-own.conf")
|
||||
a := declare(t, `{"id":"a.shared","type":"file","path":"`+shared+`","content":"x\n","module":"a"},
|
||||
{"id":"a.own","type":"file","path":"`+own+`","content":"a\n","module":"a"}`)
|
||||
b := declare(t, `{"id":"b.shared","type":"file","path":"`+shared+`","content":"x\n","module":"b"}`)
|
||||
|
||||
state := store.State{}
|
||||
var err error
|
||||
_, state, err = ApplyModule(context.Background(), archHost(t), a, Scope{Module: "a", Others: b.Resources},
|
||||
state, store.OriginDeclared, noServices, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, state, err = ApplyModule(context.Background(), archHost(t), b, Scope{Module: "b", Others: a.Resources},
|
||||
state, store.OriginDeclared, noServices, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A's want changes: it no longer declares the shared path, and b still does.
|
||||
a2 := declare(t, `{"id":"a.own","type":"file","path":"`+own+`","content":"a\n","module":"a"}`)
|
||||
report, state, err := ApplyModule(context.Background(), archHost(t), a2, Scope{Module: "a", Others: b.Resources},
|
||||
state, store.OriginDeclared, noServices, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(shared) {
|
||||
t.Fatal("the path another module still declares was removed")
|
||||
}
|
||||
if _, still := state.Find("b.shared"); !still {
|
||||
t.Fatal("the other module's record was taken away")
|
||||
}
|
||||
if _, still := state.Find("a.shared"); still {
|
||||
t.Fatal("the module's own record of the shared path was kept")
|
||||
}
|
||||
if o := outcomeOf(report, "a.shared"); o.Action != "forgotten" {
|
||||
t.Fatalf("the shared path: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// R6-A AC4: given modules A and B on a node, when A's want is removed, only A's resources are removed and B is
|
||||
// untouched.
|
||||
func TestARemovedModuleTakesOnlyItsOwnResources(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fa, fb := filepath.Join(dir, "a.conf"), filepath.Join(dir, "b.conf")
|
||||
d := declare(t, `{"id":"a.conf","type":"file","path":"`+fa+`","content":"a\n","module":"a"},
|
||||
{"id":"b.conf","type":"file","path":"`+fb+`","content":"b\n","module":"b"}`)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec, _ := state.Find("a.conf"); rec.Module != "a" {
|
||||
t.Fatalf("the owner was not recorded: %+v", rec)
|
||||
}
|
||||
|
||||
gone := &declaration.Declaration{}
|
||||
b := declare(t, `{"id":"b.conf","type":"file","path":"`+fb+`","content":"b\n","module":"b"}`)
|
||||
report, state, err := ApplyModule(context.Background(), archHost(t), gone, Scope{Module: "a", Others: b.Resources},
|
||||
state, store.OriginDeclared, noServices, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(fa) || !exists(fb) {
|
||||
t.Fatalf("a's file exists %v, b's file exists %v", exists(fa), exists(fb))
|
||||
}
|
||||
if o := outcomeOf(report, "a.conf"); o.Action != "removed" {
|
||||
t.Fatalf("a's file: %+v", o)
|
||||
}
|
||||
if _, still := state.Find("b.conf"); !still {
|
||||
t.Fatal("b's record was removed")
|
||||
}
|
||||
if _, still := state.Find("a.conf"); still {
|
||||
t.Fatal("a's record is still there")
|
||||
}
|
||||
}
|
||||
|
||||
// A record from before the owner was kept is read by its id, so the first removal after the switch is the module's too.
|
||||
func TestARecordWithoutAnOwnerBelongsToTheModuleItsIdNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fa, fb := filepath.Join(dir, "a.conf"), filepath.Join(dir, "b.conf")
|
||||
d := declare(t, `{"id":"a.conf","type":"file","path":"`+fa+`","content":"a\n"},
|
||||
{"id":"b.conf","type":"file","path":"`+fb+`","content":"b\n"}`)
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range state.Resources {
|
||||
state.Resources[i].Module = ""
|
||||
}
|
||||
_, state, err = ApplyModule(context.Background(), archHost(t), &declaration.Declaration{}, Scope{Module: "a"},
|
||||
state, store.OriginDeclared, noServices, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(fa) || !exists(fb) {
|
||||
t.Fatalf("a's file exists %v, b's file exists %v", exists(fa), exists(fb))
|
||||
}
|
||||
}
|
||||
|
||||
// A resource that leaves the node's own composition for its module's own is that module's now: the node's own apply
|
||||
// leaves it as it is, whatever its type, until the module's own apply takes it (issue 1945, live on 2026-10-25 at 02:32:
|
||||
// shanks and g14 lost systemd-resolved's guard, its resolver files and sudo's operator file, ace qbittorrent's server,
|
||||
// because the node's own apply removed every record no longer in it, the others holding the very same resources or not).
|
||||
//
|
||||
// And a resource the node's own composition carried for a module a want still places here (a folded module of the old
|
||||
// form, held) is never removed by the node's own apply when a composition leaves it out: the apply is refused whole and
|
||||
// says why (issue 774, live on 2026-10-29 at 07:54 UTC on the bus node: the node's own composition, composed without what
|
||||
// it ran once the kept declaration was read no more, removed the bus server, the proxy and three held modules). Only its
|
||||
// want's removal lets it go.
|
||||
func TestAResourceThatMovedToItsModulesCompositionIsLeftByTheNodesOwnApply(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
id string // the resource the node's own composition carried, and no longer does
|
||||
moved bool // its module's own composition now declares it (Others)
|
||||
// dropped: its module has a composition of its own here that no longer declares it (Others holds another of its
|
||||
// resources); the node's own apply leaves it, and the module's own apply removes it.
|
||||
dropped bool
|
||||
placed bool // a want still places its module here
|
||||
refused bool // the node's own apply is refused whole
|
||||
stays bool // the resource is still on the machine and recorded
|
||||
// wants names the modules whose want says removed (Scope.RemovedByWant); nil leaves the guard to Placed alone.
|
||||
wants []string
|
||||
}{
|
||||
{name: "moved to its module's own composition", id: "systemd-resolved.fact-suffix", moved: true, placed: true, stays: true},
|
||||
{name: "a held folded module, in no composition", id: "nats.server-conf", placed: true, refused: true, stays: true},
|
||||
{name: "its module's want removed", id: "nats.server-conf"},
|
||||
// Issue 774, review of the re-land, round 1: a module folded in the node's own has no want, so Placed does not name
|
||||
// it; in the node's own apply only a module a want removed loses its resources.
|
||||
{name: "a folded module with no want", id: "nats.server-conf", wants: []string{}, refused: true, stays: true},
|
||||
{name: "a folded module a want removed", id: "nats.server-conf", wants: []string{"nats", "old"}},
|
||||
// 2026-10-30 19:42 on the bus node: restic's manifest replaced its process measure by awake. Its record was the
|
||||
// node's, carried when restic was folded, so restic's own apply never saw it and the node's own was refused
|
||||
// whole every two minutes, applying nothing of the node's own.
|
||||
{name: "its module's own composition dropped it", id: "restic.measure", dropped: true, placed: true,
|
||||
wants: []string{"old"}, stays: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fact := filepath.Join(dir, "carried")
|
||||
gone := filepath.Join(dir, "gone.conf")
|
||||
own := declare(t, `{"id":"`+tc.id+`","type":"file","path":"`+fact+`","content":"mesh\n"},
|
||||
{"id":"old.gone","type":"file","path":"`+gone+`","content":"g\n"}`)
|
||||
_, state, err := ApplyModule(context.Background(), archHost(t), own, Scope{Module: store.NodeModule},
|
||||
state0(), store.OriginDeclared, noServices, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
module, _ := declaration.ModuleGuess(tc.id)
|
||||
scope := Scope{Module: store.NodeModule}
|
||||
if tc.moved {
|
||||
moved := declare(t, `{"id":"`+tc.id+`","type":"file","path":"`+fact+`","content":"mesh\n","module":"`+module+`"}`)
|
||||
scope.Others = moved.Resources
|
||||
}
|
||||
if tc.dropped {
|
||||
later := declare(t, `{"id":"`+module+`.awake","type":"file","path":"`+filepath.Join(dir, "awake")+
|
||||
`","content":"a\n","module":"`+module+`"}`)
|
||||
scope.Others = later.Resources
|
||||
}
|
||||
if tc.placed {
|
||||
scope.Placed = map[string]bool{module: true}
|
||||
}
|
||||
if tc.wants != nil {
|
||||
scope.RemovedByWant = map[string]bool{}
|
||||
for _, m := range tc.wants {
|
||||
scope.RemovedByWant[m] = true
|
||||
}
|
||||
}
|
||||
report, after, err := ApplyModule(context.Background(), archHost(t), &declaration.Declaration{}, scope, state,
|
||||
store.OriginDeclared, noServices, nil, nil, nil, nil)
|
||||
if tc.refused != (err != nil) {
|
||||
t.Fatalf("refused %v, want %v: %v", err != nil, tc.refused, err)
|
||||
}
|
||||
if tc.refused {
|
||||
if !strings.Contains(err.Error(), tc.id+" of "+module) {
|
||||
t.Fatalf("the refusal does not name what it kept: %v", err)
|
||||
}
|
||||
after = state
|
||||
}
|
||||
if exists(fact) != tc.stays {
|
||||
t.Fatalf("the carried resource exists %v, want %v: %+v", exists(fact), tc.stays, outcomeOf(report, tc.id))
|
||||
}
|
||||
if _, still := after.Find(tc.id); still != tc.stays {
|
||||
t.Fatalf("the carried resource's record kept %v, want %v", still, tc.stays)
|
||||
}
|
||||
if exists(gone) == !tc.refused {
|
||||
t.Fatalf("a resource no composition holds any more exists %v, with the apply refused %v", exists(gone), tc.refused)
|
||||
}
|
||||
if tc.dropped {
|
||||
if o := outcomeOf(report, tc.id); o.Action != "kept" {
|
||||
t.Fatalf("the node's own apply did not say it left it: %+v", o)
|
||||
}
|
||||
later := declare(t, `{"id":"`+module+`.awake","type":"file","path":"`+filepath.Join(dir, "awake")+
|
||||
`","content":"a\n","module":"`+module+`"}`)
|
||||
_, last, err := ApplyModule(context.Background(), archHost(t), later, Scope{Module: module}, after,
|
||||
store.OriginDeclared, noServices, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, still := last.Find(tc.id); still || exists(fact) {
|
||||
t.Fatalf("the module's own apply left what it no longer declares: recorded %v, on the machine %v",
|
||||
still, exists(fact))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func state0() store.State { return store.State{} }
|
||||
@@ -0,0 +1,111 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// ObjectKeep is the node-engine's own keep of the content of files given by digest (novox/hq ADR 0486 §1, issue 924): one
|
||||
// file per object, named as the object is, beside the node's state. **What a node once had it never asks the bus for
|
||||
// again**, so neither the controller's sweep of the bucket (an object is removed a day after no composition of the
|
||||
// controller names it) nor a bus that does not answer can refuse a composition the node already ran. The node's account
|
||||
// may only read its bucket (ADR 0486 §2), so the keep is the node's, not the bucket's: nothing is written back.
|
||||
//
|
||||
// An object unused for KeptObjectsFor is let go when another is kept, so the keep holds what the node applied lately and
|
||||
// not every export it ever ran. Every failure to keep is silent and costs only a fetch: the keep is a copy.
|
||||
type ObjectKeep struct {
|
||||
Dir string
|
||||
// Now is the clock; nil is time.Now.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// KeptObjectsFor is how long an object of the keep is held after it was last used.
|
||||
const KeptObjectsFor = 14 * 24 * time.Hour
|
||||
|
||||
// KeepObjectsIn is the keep at dir; an empty dir keeps nothing.
|
||||
func KeepObjectsIn(dir string) *ObjectKeep {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
return &ObjectKeep{Dir: dir}
|
||||
}
|
||||
|
||||
func (k *ObjectKeep) now() time.Time {
|
||||
if k.Now != nil {
|
||||
return k.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// get is the kept content of the object name when it is what f names, and marks it used.
|
||||
func (k *ObjectKeep) get(name string, f *declaration.File) ([]byte, bool) {
|
||||
if k == nil {
|
||||
return nil, false
|
||||
}
|
||||
path := filepath.Join(k.Dir, name)
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil || int64(len(body)) != f.Size || f.Verify(body) != nil {
|
||||
return nil, false
|
||||
}
|
||||
now := k.now()
|
||||
_ = os.Chtimes(path, now, now)
|
||||
return body, true
|
||||
}
|
||||
|
||||
// put keeps body under name, unless it is kept already (then it is marked used), and lets go of what was unused too long.
|
||||
func (k *ObjectKeep) put(name string, body []byte) {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(k.Dir, name)
|
||||
now := k.now()
|
||||
if info, err := os.Stat(path); err == nil && info.Size() == int64(len(body)) {
|
||||
_ = os.Chtimes(path, now, now)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(k.Dir, 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(k.Dir, ".put-*")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, werr := tmp.Write(body)
|
||||
cerr := tmp.Close()
|
||||
if werr != nil || cerr != nil || os.Chmod(tmp.Name(), 0o600) != nil || os.Rename(tmp.Name(), path) != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
return
|
||||
}
|
||||
_ = os.Chtimes(path, now, now)
|
||||
k.prune(now)
|
||||
}
|
||||
|
||||
// prune lets go of every object unused for KeptObjectsFor.
|
||||
func (k *ObjectKeep) prune(now time.Time) {
|
||||
entries, err := os.ReadDir(k.Dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if now.Sub(info.ModTime()) >= KeptObjectsFor {
|
||||
_ = os.Remove(filepath.Join(k.Dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// never says, in a failed fetch's error before its cause (a transient one's detail keeps the tail), that the node's own
|
||||
// keep did not hold the object either.
|
||||
func (k *ObjectKeep) never() string {
|
||||
if k == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" (this node's own keep of objects, %s, never held it)", k.Dir)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -94,6 +95,14 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
|
||||
if r.Schedule != "" {
|
||||
unit = r.Name + ".timer"
|
||||
}
|
||||
// The yielding slice is checked even then: a slice file removed by hand would leave the build unbound.
|
||||
if wrote, err := placeYieldSlice(r); err != nil {
|
||||
return out, err
|
||||
} else if wrote {
|
||||
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
if active, err := run(ctx, "systemctl", "is-active", "--quiet", unit); err == nil {
|
||||
_ = active
|
||||
return out, nil
|
||||
@@ -121,7 +130,12 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
|
||||
}
|
||||
// Replaced rather than merged: the bundle is the whole of what it runs, and files left from a
|
||||
// previous version would be loaded by a runtime that walks a directory.
|
||||
if err := os.RemoveAll(at); err != nil {
|
||||
// **Retired, never deleted under its process** (novox/hq issue 734): the old process runs until
|
||||
// the restart below, from this directory and in it. Deleted here, every command it started
|
||||
// before the restart failed with "getwd: no such file or directory" — a builder's toolchain probe
|
||||
// refused three checks on 2026-10-26. Renamed aside, its process keeps a directory that exists,
|
||||
// and the build is deleted once nothing runs from it, as a judged build's is.
|
||||
if err := witness.Retire(daemonRoot, r.Name, at, time.Now()); err != nil {
|
||||
return out, err
|
||||
}
|
||||
} else {
|
||||
@@ -189,6 +203,9 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner,
|
||||
if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if _, err := placeYieldSlice(r); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -277,6 +294,7 @@ func unitFor(r *declaration.Process) string {
|
||||
if r.User != "" {
|
||||
fmt.Fprintf(&b, "User=%s\n", r.User)
|
||||
}
|
||||
writeYields(&b, r.Yields)
|
||||
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(runFrom(r), " "))
|
||||
if r.Schedule != "" || r.RunOnce {
|
||||
// Started by its timer, or once by the host, and expected to finish. Restarting it would have it run
|
||||
@@ -293,6 +311,78 @@ func unitFor(r *declaration.Process) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// machineCPUs is how many processors this machine has, for a share of them in a quota. A variable only so a test can
|
||||
// fix it.
|
||||
var machineCPUs = runtime.NumCPU
|
||||
|
||||
// writeYields writes how a process gives way to everything else on its machine (novox/hq ADR 0553, issue 1806): a
|
||||
// build seat's holder, whose builds must never slow the operator's desktop.
|
||||
//
|
||||
// **Into the node's one yielding slice**, where the CPU weight and the quota are (yieldSlice): every yielding process
|
||||
// of the machine shares them, so three build modules take one share together, never one each. **Per process**, what a
|
||||
// slice cannot say: Nice=19 for every process it starts, and the lowest best-effort IO level. The IO level binds only
|
||||
// where the disk's scheduler honours it (bfq); under `none`, the usual one for NVMe, it does nothing. Not the idle IO
|
||||
// class: a disk that is never idle would starve a build for good.
|
||||
//
|
||||
// The share is written into the unit as a comment, so a changed share changes the unit, the daemon's identity, and the
|
||||
// slice is written again.
|
||||
func writeYields(b *strings.Builder, y *declaration.Yields) {
|
||||
if y == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "# Yields: %s, %d%% of the machine shared in %s\n", y.Priority, y.CPUShare, declaration.YieldSlice)
|
||||
fmt.Fprintf(b, "Slice=%s\n", declaration.YieldSlice)
|
||||
if y.Priority == declaration.YieldIdle {
|
||||
b.WriteString("Nice=19\nIOSchedulingClass=best-effort\nIOSchedulingPriority=7\n")
|
||||
}
|
||||
}
|
||||
|
||||
// yieldSlice is the node's one yielding slice (declaration.YieldSlice) for a process that yields: idle CPU weight and
|
||||
// IO weight 1 for an idle priority, and CPUQuota for its share of the processors this machine has (systemd counts a
|
||||
// quota in percent of one processor, so half of 24 is 1200%), at least one processor. At the top of the hierarchy, so
|
||||
// its weight and quota bind against the operator's user slice and the system's.
|
||||
//
|
||||
// The IO weight binds only where the disk's scheduler honours it (bfq, or the io.cost controller); under `none` it does
|
||||
// nothing, and the CPU bound is what holds.
|
||||
//
|
||||
// Every yielding process of a machine carries the node's one share (the composer's, ADR 0553 §3), so whichever writes
|
||||
// it writes the same file. Left in place when nothing yields any more: an empty slice binds nothing.
|
||||
func yieldSlice(y *declaration.Yields) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a yielding\n")
|
||||
b.WriteString("# process is declared again (novox/hq ADR 0553).\n")
|
||||
b.WriteString("[Unit]\nDescription=The mesh's yielding processes: builds, at idle priority under one share\n\n")
|
||||
b.WriteString("[Slice]\n")
|
||||
if y.Priority == declaration.YieldIdle {
|
||||
b.WriteString("CPUWeight=idle\nIOWeight=1\n")
|
||||
}
|
||||
if y.CPUShare > 0 {
|
||||
quota := y.CPUShare * machineCPUs()
|
||||
if quota < 100 {
|
||||
quota = 100
|
||||
}
|
||||
fmt.Fprintf(&b, "CPUQuota=%d%%\n", quota)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// placeYieldSlice writes the yielding slice for a process that yields, when what is there differs, and says whether it
|
||||
// wrote: the caller reloads the service manager before it starts anything.
|
||||
func placeYieldSlice(r *declaration.Process) (bool, error) {
|
||||
if r.Yields == nil {
|
||||
return false, nil
|
||||
}
|
||||
path := filepath.Join(unitDir, declaration.YieldSlice)
|
||||
want := yieldSlice(r.Yields)
|
||||
if have, err := os.ReadFile(path); err == nil && string(have) == want {
|
||||
return false, nil
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(want), 0o644); err != nil {
|
||||
return false, fmt.Errorf("the yielding slice %s: %w", path, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// timerFor is the cadence a scheduled process runs on.
|
||||
//
|
||||
// **The mesh's cron expression, handed to the machine's own timer.** The host already parses and
|
||||
@@ -446,7 +536,7 @@ func witnessOf(r *declaration.Process) string {
|
||||
if r.Witness != "" {
|
||||
return r.Witness
|
||||
}
|
||||
return witness.Default(r.Name)
|
||||
return witness.Default(r.ID, r.Name)
|
||||
}
|
||||
|
||||
// digestWritten is the bundle digest a process's record says was placed: the first half of what it
|
||||
|
||||
@@ -21,10 +21,16 @@ func aProcess() *declaration.Process {
|
||||
}
|
||||
}
|
||||
|
||||
// The unit the mesh writes says what it runs, where, and that it comes back.
|
||||
// The unit the mesh writes says what it runs, where, and that it comes back — and that it is the
|
||||
// mesh's: **generated whole and saying so**, because an edit that survives until the next declaration
|
||||
// and then vanishes is worse than one that is refused. The long-running mode stays up and is not a step.
|
||||
func TestTheUnitRunsWhatTheDaemonSaid(t *testing.T) {
|
||||
unit := unitFor(aProcess())
|
||||
if !strings.HasPrefix(unit, "#") {
|
||||
t.Fatalf("the unit does not open saying it is generated:\n%s", unit)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Do not edit",
|
||||
"ExecStart=node index.js",
|
||||
"WorkingDirectory=/var/lib/mesh/daemons/greeter",
|
||||
"Restart=always",
|
||||
@@ -34,14 +40,8 @@ func TestTheUnitRunsWhatTheDaemonSaid(t *testing.T) {
|
||||
t.Fatalf("the unit does not say %q:\n%s", want, unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **Generated whole and saying so.** Every managed file on a machine carries this, because an edit
|
||||
// that survives until the next declaration and then vanishes is worse than one that is refused.
|
||||
func TestTheUnitSaysItIsTheMeshs(t *testing.T) {
|
||||
unit := unitFor(aProcess())
|
||||
if !strings.HasPrefix(unit, "#") || !strings.Contains(unit, "Do not edit") {
|
||||
t.Fatalf("the unit does not say it is generated:\n%s", unit)
|
||||
if strings.Contains(unit, "Type=oneshot") {
|
||||
t.Fatalf("a process that should stay up is declared a step:\n%s", unit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,60 @@ func TestAProcessRunsAsWhoItSaid(t *testing.T) {
|
||||
if strings.Contains(unitFor(aProcess()), "User=") {
|
||||
t.Fatalf("a process that named no user had one written for it:\n%s", unitFor(aProcess()))
|
||||
}
|
||||
|
||||
// A process that yields (ADR 0553) runs in the node's one yielding slice at nice 19; the slice holds the idle weight
|
||||
// and the quota for its share of the machine's processors; one that does not yield has none of it.
|
||||
defer func(n func() int) { machineCPUs = n }(machineCPUs)
|
||||
for _, c := range []struct {
|
||||
cpus int
|
||||
y *declaration.Yields
|
||||
unit, slice, not []string
|
||||
}{
|
||||
{24, &declaration.Yields{Priority: declaration.YieldIdle, CPUShare: 50},
|
||||
[]string{"Slice=meshyield.slice\n", "Nice=19\n", "IOSchedulingPriority=7\n", "50%"},
|
||||
[]string{"CPUWeight=idle\n", "IOWeight=1\n", "CPUQuota=1200%\n"}, []string{"CPUQuota=", "CPUWeight="}},
|
||||
{1, &declaration.Yields{CPUShare: 25}, []string{"Slice=meshyield.slice\n"}, []string{"CPUQuota=100%\n"},
|
||||
[]string{"Nice="}},
|
||||
{24, nil, nil, nil, []string{"Slice=", "Nice=", "CPUQuota="}},
|
||||
} {
|
||||
machineCPUs = func() int { return c.cpus }
|
||||
p := aProcess()
|
||||
p.Yields = c.y
|
||||
unit := unitFor(p)
|
||||
for _, w := range c.unit {
|
||||
if !strings.Contains(unit, w) {
|
||||
t.Errorf("yields %+v: the unit does not say %q:\n%s", c.y, w, unit)
|
||||
}
|
||||
}
|
||||
for _, n := range c.not {
|
||||
if strings.Contains(unit, n) {
|
||||
t.Errorf("yields %+v: the unit says %q:\n%s", c.y, n, unit)
|
||||
}
|
||||
}
|
||||
if c.y == nil {
|
||||
continue
|
||||
}
|
||||
slice := yieldSlice(c.y)
|
||||
for _, w := range c.slice {
|
||||
if !strings.Contains(slice, w) {
|
||||
t.Errorf("yields %+v on %d processors: the slice does not say %q:\n%s", c.y, c.cpus, w, slice)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The slice is placed beside the units, once, and again only when it differs.
|
||||
was := unitDir
|
||||
unitDir = t.TempDir()
|
||||
defer func() { unitDir = was }()
|
||||
p := aProcess()
|
||||
p.Yields = &declaration.Yields{Priority: declaration.YieldIdle, CPUShare: 50}
|
||||
for i, want := range []bool{true, false} {
|
||||
if wrote, err := placeYieldSlice(p); err != nil || wrote != want {
|
||||
t.Errorf("placing %d: wrote %v (%v), want %v", i, wrote, err, want)
|
||||
}
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(unitDir, "meshyield.slice")); err != nil || !strings.Contains(string(b), "[Slice]") {
|
||||
t.Errorf("the slice file: %q %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
// **Three modes, one kind.** A scheduled process is a timer plus a unit that finishes, not a unit
|
||||
@@ -124,17 +178,6 @@ func TestACronBecomesATimersCalendar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// And the long-running mode is unchanged by any of it: it stays up and comes back.
|
||||
func TestAProcessThatStaysUpIsStillRestartedWhenItExits(t *testing.T) {
|
||||
unit := unitFor(aProcess())
|
||||
if !strings.Contains(unit, "Restart=always") {
|
||||
t.Fatalf("a process that should stay up is not restarted when it exits:\n%s", unit)
|
||||
}
|
||||
if strings.Contains(unit, "Type=oneshot") {
|
||||
t.Fatalf("a process that should stay up is declared a step:\n%s", unit)
|
||||
}
|
||||
}
|
||||
|
||||
// **A unit file reinterprets a value in three ways nothing else does**, and a module's environment
|
||||
// routinely contains all three — a generated password is arbitrary bytes.
|
||||
func TestAnEnvironmentValueMeansWhatTheDeclarationSaid(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,9 @@ package apply
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -70,36 +72,112 @@ func TestARolledBackBuildIsNotPlacedAgain(t *testing.T) {
|
||||
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) { return "", nil }
|
||||
|
||||
oldBody, oldDigest := anArchive(t, map[string]string{"mesh-controller": "old\n"})
|
||||
newBody, newDigest := anArchive(t, map[string]string{"mesh-controller": "new\n"})
|
||||
controller := func(body []byte, digest, env string) string {
|
||||
return `{"id":"mesh-controller.controller","type":"process","name":"mesh-controller","source":"` + serving(t, body) +
|
||||
`","digest":"` + digest + `","run":["./mesh-controller","serve"],"env":{"X":"` + env + `"}}`
|
||||
oldBody, oldDigest := anArchive(t, map[string]string{"node-tools": "old\n"})
|
||||
newBody, newDigest := anArchive(t, map[string]string{"node-tools": "new\n"})
|
||||
runtime := func(body []byte, digest, env string) string {
|
||||
return `{"id":"node-tools.runtime","type":"process","name":"node-tools","source":"` + serving(t, body) +
|
||||
`","digest":"` + digest + `","run":["./node-tools"],"env":{"X":"` + env + `"}}`
|
||||
}
|
||||
_, state, err := Apply(context.Background(), archHost(t), declare(t, controller(oldBody, oldDigest, "1")), store.State{},
|
||||
_, state, err := Apply(context.Background(), archHost(t), declare(t, runtime(oldBody, oldDigest, "1")), store.State{},
|
||||
store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, state, err = Apply(context.Background(), archHost(t), declare(t, controller(newBody, newDigest, "1")), state,
|
||||
_, state, err = Apply(context.Background(), archHost(t), declare(t, runtime(newBody, newDigest, "1")), state,
|
||||
store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := witness.Restore(context.Background(), bundles, "mesh-controller", "never took the lease", witness.Runner(run),
|
||||
if _, err := witness.Restore(context.Background(), bundles, "node-tools", "never answered", witness.Runner(run),
|
||||
time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The mesh still declares the new build, and its unit changes: the process is re-placed.
|
||||
report, _, err := Apply(context.Background(), archHost(t), declare(t, controller(newBody, newDigest, "2")), state,
|
||||
report, _, err := Apply(context.Background(), archHost(t), declare(t, runtime(newBody, newDigest, "2")), state,
|
||||
store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(bundles, "mesh-controller", "mesh-controller")); string(got) != "old\n" {
|
||||
if got, _ := os.ReadFile(filepath.Join(bundles, "node-tools", "node-tools")); string(got) != "old\n" {
|
||||
t.Fatalf("the rolled-back build was placed again: %q", got)
|
||||
}
|
||||
if o := outcomeOf(report, "mesh-controller.controller"); !strings.Contains(o.Detail, "rolled back on this machine") {
|
||||
if o := outcomeOf(report, "node-tools.runtime"); !strings.Contains(o.Detail, "rolled back on this machine") {
|
||||
t.Fatalf("the apply does not say why it kept the build before: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// **A process nobody judges keeps the directory it works in until its restart** (novox/hq issue 734).
|
||||
// On 2026-10-26 three checks on one builder were refused in the second a new build of it arrived: the
|
||||
// applier deleted the running build's directory before the restart, and the `go version` the old
|
||||
// process started in it failed with "getwd: no such file or directory". Here a process works in the
|
||||
// build's directory without running from it, as that `go version` did: the apply of a new build leaves
|
||||
// its directory in place, and the retired build goes once nothing works in it.
|
||||
func TestAProcessNobodyJudgesKeepsItsDirectoryUntilItsRestart(t *testing.T) {
|
||||
if _, err := os.Stat("/proc/self/cwd"); err != nil {
|
||||
t.Skip("which process works in a build is read from /proc")
|
||||
}
|
||||
units, bundles := t.TempDir(), t.TempDir()
|
||||
wasUnits, wasBundles := unitDir, daemonRoot
|
||||
unitDir, daemonRoot = units, bundles
|
||||
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) { return "", nil }
|
||||
|
||||
oldBody, oldDigest := anArchive(t, map[string]string{"mesh-build-go": "old\n"})
|
||||
newBody, newDigest := anArchive(t, map[string]string{"mesh-build-go": "new\n"})
|
||||
builder := func(body []byte, digest string) string {
|
||||
return `{"id":"mesh-build-go.builder","type":"process","name":"mesh-build-go","source":"` + serving(t, body) +
|
||||
`","digest":"` + digest + `","run":["./mesh-build-go"]}`
|
||||
}
|
||||
_, state, err := Apply(context.Background(), archHost(t), declare(t, builder(oldBody, oldDigest)), store.State{},
|
||||
store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sleep, err := exec.LookPath("sleep")
|
||||
if err != nil {
|
||||
t.Skip("no sleep to run")
|
||||
}
|
||||
working := exec.Command(sleep, "60")
|
||||
working.Dir = filepath.Join(bundles, "mesh-build-go")
|
||||
if err := working.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = working.Process.Kill(); _ = working.Wait() })
|
||||
cwd := filepath.Join("/proc", strconv.Itoa(working.Process.Pid), "cwd")
|
||||
for deadline := time.Now().Add(5 * time.Second); ; {
|
||||
if at, err := os.Readlink(cwd); err == nil && at == working.Dir {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("PID %d does not work in %s within 5s", working.Process.Pid, working.Dir)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
if _, _, err := Apply(context.Background(), archHost(t), declare(t, builder(newBody, newDigest)), state,
|
||||
store.OriginDeclared, run, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(bundles, "mesh-build-go", "mesh-build-go")); string(got) != "new\n" {
|
||||
t.Fatalf("the new build is not where the unit runs it from: %q", got)
|
||||
}
|
||||
at, err := os.Readlink(cwd)
|
||||
if err != nil || strings.HasSuffix(at, " (deleted)") {
|
||||
t.Fatalf("the directory a running process works in was deleted before its restart: %q (%v)", at, err)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(at, "mesh-build-go")); string(got) != "old\n" {
|
||||
t.Fatalf("the process's directory no longer holds its own build: %q", got)
|
||||
}
|
||||
if len(witness.Trials(bundles)) != 0 {
|
||||
t.Fatal("a process nobody judges went on trial")
|
||||
}
|
||||
|
||||
// Its process gone, the next look deletes the retired build and keeps nothing about the process.
|
||||
_ = working.Process.Kill()
|
||||
_ = working.Wait()
|
||||
witness.SweepAll(bundles)
|
||||
if _, err := os.Stat(witness.Dir(bundles, "mesh-build-go")); !os.IsNotExist(err) {
|
||||
t.Fatalf("a retired build nothing works in was kept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,23 @@ type FetchObject func(ctx context.Context, name string, limit int64) ([]byte, er
|
||||
// unchanged declaration, and a node that is asleep and has no bus, apply as they always did. One object is fetched
|
||||
// once however many files name its digest.
|
||||
func ResolveFileReferences(ctx context.Context, d *declaration.Declaration, fetch FetchObject) error {
|
||||
return ResolveFileReferencesKept(ctx, d, fetch, nil)
|
||||
}
|
||||
|
||||
// ResolveFileReferencesKept is ResolveFileReferences with the node's own keep of objects (ObjectKeep): every content had
|
||||
// (a file here that verifies, or an object of the bus) is kept, and a kept one is used before the bus is asked. **A node
|
||||
// depends on its bucket only for content it never had** (issue 924, 2026-10-29: novox's own composition named the
|
||||
// export mesh-vault runs by a digest whose object the controller's sweep had removed, so the vault's apply failed while
|
||||
// it ran; the bucket's objects are kept only while a composition of the controller names them, and a node composing its
|
||||
// own names objects the controller no longer knows of). A nil keep keeps nothing.
|
||||
func ResolveFileReferencesKept(ctx context.Context, d *declaration.Declaration, fetch FetchObject, keep *ObjectKeep) error {
|
||||
fetched := map[string][]byte{}
|
||||
for i, r := range d.Resources {
|
||||
f, ok := r.(*declaration.File)
|
||||
if !ok || !f.IsReference() {
|
||||
continue
|
||||
}
|
||||
body, err := referencedContent(ctx, f, fetch, fetched)
|
||||
body, err := referencedContent(ctx, f, fetch, fetched, keep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -50,31 +60,37 @@ func ResolveFileReferences(ctx context.Context, d *declaration.Declaration, fetc
|
||||
}
|
||||
|
||||
func referencedContent(ctx context.Context, f *declaration.File, fetch FetchObject,
|
||||
fetched map[string][]byte) ([]byte, error) {
|
||||
fetched map[string][]byte, keep *ObjectKeep) ([]byte, error) {
|
||||
if body, ok := fetched[f.Digest]; ok {
|
||||
return body, nil
|
||||
}
|
||||
if body, ok := alreadyThere(f); ok {
|
||||
fetched[f.Digest] = body
|
||||
return body, nil
|
||||
}
|
||||
name, err := declaration.ObjectName(f.Digest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file %s (%s): %w", f.ID, f.Path, err)
|
||||
}
|
||||
if body, ok := alreadyThere(f); ok {
|
||||
fetched[f.Digest] = body
|
||||
keep.put(name, body)
|
||||
return body, nil
|
||||
}
|
||||
if body, ok := keep.get(name, f); ok {
|
||||
fetched[f.Digest] = body
|
||||
return body, nil
|
||||
}
|
||||
if fetch == nil {
|
||||
return nil, fmt.Errorf("file %s (%s) is given by digest %s and is not on this machine, and this node-engine "+
|
||||
"has no link to the bus to fetch it from", f.ID, f.Path, f.Digest)
|
||||
}
|
||||
body, err := fetch(ctx, name, f.Size)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file %s (%s): fetching the object %s of this node's own object bucket: %w",
|
||||
f.ID, f.Path, name, err)
|
||||
return nil, fmt.Errorf("file %s (%s): fetching the object %s of this node's own object bucket%s: %w",
|
||||
f.ID, f.Path, name, keep.never(), err)
|
||||
}
|
||||
if err := f.Verify(body); err != nil {
|
||||
return nil, fmt.Errorf("%s (%s): %w; nothing was applied", f.ID, f.Path, err)
|
||||
}
|
||||
fetched[f.Digest] = body
|
||||
keep.put(name, body)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,63 @@ func TestARunningContainerIsKeptWhenItsReplacementImageCannotBeObtained(t *testi
|
||||
if !strings.Contains(err.Error(), pinned) {
|
||||
t.Errorf("the failure does not name the image %s: %v", pinned, err)
|
||||
}
|
||||
var pull *PullError
|
||||
if !errors.As(err, &pull) || pull.Image != pinned {
|
||||
t.Errorf("the failure is not a pull error naming %s: %v", pinned, err)
|
||||
}
|
||||
|
||||
// design 51, ENG-7: a module of two containers whose second new image cannot be pulled replaces neither, and says the
|
||||
// image; with both pullable, both are pulled before the first is set aside.
|
||||
second := strings.Replace(pinned, "postgres", "other", 1)
|
||||
two := `{"declaration":1,"resources":[
|
||||
{"id":"a.first","type":"container","name":"first","image":"` + pinned + `","env":{"X":"1"}},
|
||||
{"id":"a.second","type":"container","name":"second","image":"` + second + `","env":{"X":"2"}}
|
||||
]}`
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
unpullable string
|
||||
wantTouched bool
|
||||
wantPulled string
|
||||
}{
|
||||
{"the second image cannot be pulled", second, false, "pull " + pinned},
|
||||
{"both can", "", true, "pull " + pinned + "; pull " + second},
|
||||
} {
|
||||
d := parseTrusted(t, two)
|
||||
var steps []string
|
||||
present := map[string]bool{}
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
args = oldShape(name, args)
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "container":
|
||||
return "true\tsome-older-spec", nil
|
||||
case "image":
|
||||
if present[args[len(args)-1]] {
|
||||
return "", nil
|
||||
}
|
||||
return "", errors.New("no such image")
|
||||
case "pull":
|
||||
if args[1] == c.unpullable {
|
||||
return "", errors.New("manifest unknown")
|
||||
}
|
||||
present[args[1]] = true
|
||||
steps = append(steps, "pull "+args[1])
|
||||
case "rm", "rename", "stop", "run":
|
||||
steps = append(steps, args[0]+" "+strings.Join(args[1:], " "))
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
got := strings.Join(steps, "; ")
|
||||
touched := strings.Contains(got, "rename") || strings.Contains(got, "run ")
|
||||
if touched != c.wantTouched || !strings.HasPrefix(got, c.wantPulled) {
|
||||
t.Errorf("%s: steps %q (touched %v), want pulls %q first and touched=%v", c.name, got, touched, c.wantPulled, c.wantTouched)
|
||||
}
|
||||
if !c.wantTouched && (err == nil || !strings.Contains(err.Error(), second)) {
|
||||
t.Errorf("%s: the failure does not name %s: %v", c.name, second, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReplacementImageIsPresentBeforeTheRunningContainerIsRemoved(t *testing.T) {
|
||||
@@ -54,6 +111,16 @@ func TestAReplacementImageIsPresentBeforeTheRunningContainerIsRemoved(t *testing
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
want := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
|
||||
// The seat and holder a routed image names (ENG-10) are no part of what a container is: another holder is the same spec,
|
||||
// so a declaration composed again for it replaces no container.
|
||||
for _, holder := range []string{"distribution", "elsewhere"} {
|
||||
named := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","image-seat":"mesh-artifact-store","image-holder":"`+holder+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
if got := containerSpec(named.Resources[0].(*declaration.Container), inputs{}); got != want {
|
||||
t.Errorf("a container naming the holder %s has another spec than one naming none", holder)
|
||||
}
|
||||
}
|
||||
var order []string
|
||||
created := false
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
@@ -91,3 +158,129 @@ func TestAReplacementImageIsPresentBeforeTheRunningContainerIsRemoved(t *testing
|
||||
t.Errorf("order of runtime calls = %s, want the image pulled before rm", got)
|
||||
}
|
||||
}
|
||||
|
||||
// asideRun is a runtime whose container "store" runs from an older spec, recording the steps a replacement takes; run
|
||||
// fails when failRun, and the new container reads back as declared otherwise.
|
||||
func asideRun(t *testing.T, want string, failRun bool, order *[]string) Runner {
|
||||
created := false
|
||||
return func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "container":
|
||||
if created {
|
||||
return "true\t" + want, nil
|
||||
}
|
||||
return "true\tsome-older-spec", nil
|
||||
case "image":
|
||||
return "", nil
|
||||
case "rm", "rename", "stop", "start":
|
||||
*order = append(*order, strings.Join(args, " "))
|
||||
return "", nil
|
||||
case "run":
|
||||
*order = append(*order, "run")
|
||||
if failRun {
|
||||
return "", errors.New("port is already allocated")
|
||||
}
|
||||
created = true
|
||||
return "deadbeef\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// A container whose replacement does not come up is put back: the running one is set aside (renamed and stopped), never
|
||||
// removed before the new one runs, and started again under its name when the new one fails, the apply failing loudly.
|
||||
// Removed first, the bus's own container left novox without a bus when its replacement was never made (issue 1945,
|
||||
// live on 2026-10-25 at 11:23).
|
||||
func TestAContainerWhoseReplacementFailsIsPutBack(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
want := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
|
||||
var order []string
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, asideRun(t, want, true, &order), nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "started again") {
|
||||
t.Fatalf("a failed replacement: %v", err)
|
||||
}
|
||||
got := strings.Join(order, "; ")
|
||||
if !strings.Contains(got, "rename store store.replaced; stop store.replaced; run") ||
|
||||
!strings.Contains(got, "rename store.replaced store; start store") {
|
||||
t.Fatalf("the steps were %q", got)
|
||||
}
|
||||
if strings.Contains(got, "rm -f store;") && strings.Index(got, "rm -f store;") < strings.Index(got, "run") {
|
||||
t.Fatalf("the running container was removed before its replacement ran: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A replacement that runs removes the container set aside, and only after it runs.
|
||||
func TestAReplacementThatRunsRemovesTheContainerSetAside(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
want := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
|
||||
var order []string
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, asideRun(t, want, false, &order), nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Join(order, "; ")
|
||||
run, removed := strings.Index(got, "run"), strings.LastIndex(got, "rm -f store.replaced")
|
||||
if run < 0 || removed < run || strings.Contains(got, "start store") {
|
||||
t.Fatalf("the steps were %q: the set-aside container is removed after the replacement runs", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A container left set aside by an engine that stopped between setting it aside and running its replacement is the
|
||||
// set-aside one of the next apply: put back when the replacement fails, removed once it runs (the review of issue
|
||||
// 1945).
|
||||
func TestAContainerLeftAsideIsPutBackOrRemovedByTheNextApply(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
want := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
|
||||
for _, failRun := range []bool{true, false} {
|
||||
var order []string
|
||||
created := false
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "container":
|
||||
switch args[len(args)-1] {
|
||||
case "store":
|
||||
if created {
|
||||
return "true\t" + want, nil
|
||||
}
|
||||
return "", errors.New("no such container")
|
||||
case "store.replaced":
|
||||
return "false\tsome-older-spec", nil
|
||||
}
|
||||
return "", errors.New("no such container")
|
||||
case "image":
|
||||
return "", nil
|
||||
case "rm", "rename", "stop", "start":
|
||||
order = append(order, strings.Join(args, " "))
|
||||
return "", nil
|
||||
case "run":
|
||||
order = append(order, "run")
|
||||
if failRun {
|
||||
return "", errors.New("port is already allocated")
|
||||
}
|
||||
created = true
|
||||
return "deadbeef\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
got := strings.Join(order, "; ")
|
||||
if failRun {
|
||||
if err == nil || !strings.Contains(got, "rename store.replaced store; start store") {
|
||||
t.Fatalf("a failed run beside a container left aside: %v; steps %q", err, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || !strings.Contains(got, "run; rm -f store.replaced") {
|
||||
t.Fatalf("a run beside a container left aside: %v; steps %q", err, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,17 @@ func (s *Scheduler) RecordWindowsIn(w *Windows) {
|
||||
// work on the predecessor's data, under the predecessor's service, which is the one thing an
|
||||
// adopted node must not do. Nil on a converged node, where nothing is held.
|
||||
func (s *Scheduler) Sync(d *declaration.Declaration, held map[string]bool) {
|
||||
s.sync(d, held, nil)
|
||||
}
|
||||
|
||||
// SyncModule is Sync for one module's declaration (novox/hq ADR 0539 §9): the schedules of that module are armed,
|
||||
// re-armed or forgotten, and the schedules of every other module are left as they are. owns says whether a scheduled
|
||||
// step's id is the module's.
|
||||
func (s *Scheduler) SyncModule(d *declaration.Declaration, held map[string]bool, owns func(id string) bool) {
|
||||
s.sync(d, held, owns)
|
||||
}
|
||||
|
||||
func (s *Scheduler) sync(d *declaration.Declaration, held map[string]bool, owns func(id string) bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -177,7 +188,7 @@ func (s *Scheduler) Sync(d *declaration.Declaration, held map[string]bool) {
|
||||
}
|
||||
|
||||
for id := range s.jobs {
|
||||
if !seen[id] {
|
||||
if !seen[id] && (owns == nil || owns(id)) {
|
||||
delete(s.jobs, id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Package beside is where a test finds another repository of the mesh it reads: the catalogue's manifests,
|
||||
// the node-engine's genesis template (novox/hq issue 432), the SDK's conformance fixtures (issue 449).
|
||||
//
|
||||
// Copied from mesh-controller internal/beside at commit 6c2a8d62 (R4b, issue 1379); the one change is that Dir
|
||||
// lays out the captured copy of a repository once per process, not once per test (see laidOut). In mesh-sdk the
|
||||
// catalogue's tests read mesh-catalog and mesh-host only; the captured copy holds just what they read.
|
||||
//
|
||||
// A test used to read the checkout beside this one, `../../../mesh-catalog`, so its verdict depended on
|
||||
// whatever sat on the machine running it: a stale or dirty checkout failed it on a desktop, and where none
|
||||
// was beside it skipped and said nothing. Now there are two inputs, both named, and no third:
|
||||
//
|
||||
// - In a merge check, the build seat clones each core repository beside the one checked (the catalogue
|
||||
// at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A
|
||||
// test judges against those clones, so agreement with the other repository is checked where
|
||||
// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a
|
||||
// check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host
|
||||
// by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's
|
||||
// go.mod pins. Each repository's tests read these siblings, and the checker places exactly these
|
||||
// (mesh-delivery's checker, besideRules, follows this list): mesh-controller's read mesh-catalog,
|
||||
// mesh-host and mesh-sdk; mesh-host's read mesh-catalog, mesh-sdk and mesh-controller (its step-nine
|
||||
// test reads the controller's own manifest, novox/hq issue 1295); mesh-sdk's read mesh-catalog and
|
||||
// mesh-host; mesh-catalog's read mesh-sdk and mesh-controller; mesh-delivery's read mesh-sdk and
|
||||
// mesh-catalog. In a mesh where either module has no source repository nothing is
|
||||
// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the
|
||||
// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the
|
||||
// catalogue's main, not the group's head.
|
||||
// - Anywhere else, the test judges against the copy captured in this repository's testdata/beside, at the
|
||||
// commit testdata/beside/CAPTURED names. Never against a developer's own checkout: to judge one, set
|
||||
// MESH_CHECK_BESIDE to the directory holding it, as the check does.
|
||||
//
|
||||
// The captured manifests are named module.json.captured, and put back as module.json in a directory of
|
||||
// the test's own: a module.json anywhere in this repository is a module of it to the forge's announcer and
|
||||
// the planner, and a merge would build and register a hundred copies of the catalogue's.
|
||||
package beside
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Env is where a merge check says the repositories cloned beside the one checked are (internal/builder's
|
||||
// EnvBeside, repeated here so a test does not import the builder).
|
||||
const Env = "MESH_CHECK_BESIDE"
|
||||
|
||||
// CapturedSuffix is what a captured file's name carries that the repository's own does not.
|
||||
const CapturedSuffix = ".captured"
|
||||
|
||||
// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and
|
||||
// says which in the test's log.
|
||||
func Dir(t testing.TB, repository string) string {
|
||||
t.Helper()
|
||||
if root := os.Getenv(Env); root != "" {
|
||||
dir := filepath.Join(root, repository)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+
|
||||
"judged here: %v", repository, Env, root, err)
|
||||
}
|
||||
t.Logf("judged against %s as cloned beside this check", dir)
|
||||
return dir
|
||||
}
|
||||
from := filepath.Join(Captured(), repository)
|
||||
if _, err := os.Stat(from); err != nil {
|
||||
t.Fatalf("no captured copy of %s at %s: %v", repository, from, err)
|
||||
}
|
||||
dir, err := layOut(repository, from)
|
||||
if err != nil {
|
||||
t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err)
|
||||
}
|
||||
t.Logf("judged against the copy of %s captured in testdata/beside (see CAPTURED); a merge check "+
|
||||
"judges it against the repository's own clone", repository)
|
||||
return dir
|
||||
}
|
||||
|
||||
// laidOut is the captured copies already put back in place in this process, by repository. A copy is
|
||||
// read-only to every test, so it is laid out once per process and not once per test: with a copy per test
|
||||
// the catalogue's tests alone took 2.14 s, over mesh-sdk's unit bound of 2 s, and with one 0.81 s (measured
|
||||
// 2026-10-20). The directories are in the temp directory, which the system clears; a process cannot remove
|
||||
// them while another test may still read them.
|
||||
var (
|
||||
laidOutMu sync.Mutex
|
||||
laidOut = map[string]string{}
|
||||
)
|
||||
|
||||
func layOut(repository, from string) (string, error) {
|
||||
laidOutMu.Lock()
|
||||
defer laidOutMu.Unlock()
|
||||
if dir, ok := laidOut[repository]; ok {
|
||||
return dir, nil
|
||||
}
|
||||
root, err := os.MkdirTemp("", "beside-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(root, repository)
|
||||
err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(from, path)
|
||||
into := filepath.Join(dir, strings.TrimSuffix(rel, CapturedSuffix))
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(into, 0o755)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(into, raw, 0o644)
|
||||
})
|
||||
if err != nil {
|
||||
os.RemoveAll(root)
|
||||
return "", err
|
||||
}
|
||||
laidOut[repository] = dir
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// Catalogue is the catalogue's modules directory, as Dir finds the catalogue.
|
||||
func Catalogue(t testing.TB) string {
|
||||
t.Helper()
|
||||
return filepath.Join(Dir(t, "mesh-catalog"), "modules")
|
||||
}
|
||||
|
||||
// Captured is this repository's testdata/beside, found from this file rather than from the working
|
||||
// directory, so a test in any package reaches it.
|
||||
func Captured() string {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "beside")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -185,7 +185,7 @@ func (r Rewritten) declares(id string) bool {
|
||||
// container runtime's configuration whole — and neither is anything the operator installs later.
|
||||
var genesisTakes = map[string]bool{
|
||||
RegistryModule: true, ControlPlaneModule: true, BuilderModule: true,
|
||||
"postgres": true, BusModule: true, "mesh-vault": true, "mesh-catalog": true,
|
||||
"postgres": true, BusModule: true, "mesh-vault": true,
|
||||
}
|
||||
|
||||
// takeIfAdopted takes one of genesis's own modules on an adopted node, once it is assigned and
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user