Compare commits
244
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 |
@@ -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
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func TestAnApplyOutlivesTheLinksContext(t *testing.T) {
|
||||
// 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()
|
||||
|
||||
@@ -2,12 +2,12 @@ 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"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// authorityPath is where this engine keeps the mesh's own certificate as it last applied it (public): beside the
|
||||
@@ -17,7 +17,7 @@ func authorityPath(state string) string { return filepath.Join(filepath.Dir(stat
|
||||
// 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 delivery.Composed) error {
|
||||
func keepAuthority(state string, c engcomp.Composed) error {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -7,14 +7,16 @@ import (
|
||||
"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-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"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"
|
||||
)
|
||||
@@ -28,66 +30,60 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{"id": id, "type": "file", "path": "/etc/x", "content": content})
|
||||
return raw
|
||||
}
|
||||
if err := keepAuthority(state, delivery.Composed{Resources: []json.RawMessage{res("web.conf", "x")}}); err != nil {
|
||||
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, delivery.Composed{Resources: []json.RawMessage{res("ca-trust.certificate-authority", "CA-1")}}); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// L3 s6: before the vault's signers are read the node trusts the controller's key alone; once read, a message the
|
||||
// vault signed is accepted and the record names both; once the lease is handed away the controller's key is refused
|
||||
// and the record names the vault's alone. Each change tells the record's writer.
|
||||
// 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(controllerPub)
|
||||
trust := newMeshTrust()
|
||||
told := 0
|
||||
trust.onChange(func() { told++ })
|
||||
keys := recordKeys(state, trust.Trusts)
|
||||
ctrlFP, vaultFP := delivery.SignerFingerprint(controllerPub), delivery.SignerFingerprint(vaultPub)
|
||||
if got := keys().Trusts; !delivery.TrustsKey(got, delivery.SignerController, ctrlFP) || delivery.TrustsKey(got, delivery.SignerVault, vaultFP) {
|
||||
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)
|
||||
}
|
||||
enrol, _ := delivery.SignContext(delivery.KindEnrolment)
|
||||
msg := []byte("a reply")
|
||||
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err == nil {
|
||||
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(delivery.NewSignerKey(vaultPub, time.Now()))
|
||||
fp, _ := delivery.AuthorityFingerprint(vaultCA)
|
||||
authValue, _ := json.Marshal(delivery.SignerAuthority{Certificate: vaultCA, Fingerprint: fp, Made: time.Now()})
|
||||
trust.heardSigner(delivery.SignersKeyKey, keyValue, false, func(string) {})
|
||||
trust.heardSigner(delivery.SignersAuthorityKey, authValue, false, func(string) {})
|
||||
if s, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err != nil || s.Name != delivery.SignerVault {
|
||||
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 got := keys().Trusts; !delivery.TrustsKey(got, delivery.SignerController, ctrlFP) || !delivery.TrustsKey(got, delivery.SignerVault, vaultFP) ||
|
||||
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)
|
||||
}
|
||||
handed, _ := json.Marshal(buscompose.LeaseHolder{HandedTo: "mesh-vault", HandedBy: "person.operator"})
|
||||
trust.heardLease(handed, true, nil)
|
||||
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil || trust.Controller() != nil {
|
||||
t.Error("after the lease is handed away the controller's key is still trusted")
|
||||
}
|
||||
if got := keys().Trusts; delivery.TrustsKey(got, delivery.SignerController, ctrlFP) || !delivery.TrustsKey(got, delivery.SignerVault, vaultFP) || told != 3 {
|
||||
t.Errorf("after the lease is handed away the record trusts %v (told %d)", got, told)
|
||||
}
|
||||
trust.heardLease(handed, true, nil)
|
||||
if told != 3 {
|
||||
t.Error("a lease heard again unchanged wrote the record again")
|
||||
}
|
||||
// A signers' key naming another fingerprint is not trusted.
|
||||
bad, _ := json.Marshal(delivery.SignerKey{Key: base64.StdEncoding.EncodeToString(controllerPub), Fingerprint: vaultFP})
|
||||
trust.heardSigner(delivery.SignersKeyKey, bad, false, func(string) {})
|
||||
if !delivery.TrustsKey(keys().Trusts, delivery.SignerVault, vaultFP) {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -102,8 +98,8 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
|
||||
}
|
||||
servingPub, _ := base64.StdEncoding.DecodeString(serving.Public)
|
||||
signedBy := func(ca string, key ed25519.PrivateKey) string {
|
||||
parent, _ := delivery.ParseCertificate(ca)
|
||||
der, err := x509.CreateCertificate(nil, delivery.NodeCertificateTemplate(overlay.InternalName("n1"), servingPub, time.Now().Add(-time.Hour)),
|
||||
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)
|
||||
@@ -114,25 +110,57 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
|
||||
answer := signedBy(vaultCA, vault)
|
||||
ask := func(_ context.Context, verb string, args any) (json.RawMessage, error) {
|
||||
asked = append(asked, verb, args)
|
||||
return json.Marshal(delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: "n1", Certificate: answer})
|
||||
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] != delivery.SignVerb {
|
||||
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 := delivery.AuthorityTemplate(pub, time.Now().Add(-time.Hour))
|
||||
tpl := meshvaultclient.AuthorityTemplate(pub, time.Now().Add(-time.Hour))
|
||||
der, err := x509.CreateCertificate(nil, tpl, tpl, pub, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -3,9 +3,17 @@ 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 {
|
||||
@@ -39,4 +47,103 @@ func TestABackupEndsWhenItsEventSaysSo(t *testing.T) {
|
||||
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...)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// A composition that names a file the provisioner fills without saying it is filled would have this node-engine write
|
||||
@@ -13,7 +12,7 @@ import (
|
||||
// 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 := delivery.Composed{Node: "ace", Resources: []json.RawMessage{unit}}
|
||||
c := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{unit}}
|
||||
d, err := declarationOf(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -25,7 +24,7 @@ func TestAFileItCannotProduceIsNeverWrittenEmpty(t *testing.T) {
|
||||
if d, _ = declarationOf(c); refuseEmptyWrites(d) != nil {
|
||||
t.Fatalf("a filled file was refused: %v", refuseEmptyWrites(d))
|
||||
}
|
||||
plain := delivery.Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage(
|
||||
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))
|
||||
|
||||
+120
-5
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -12,12 +13,14 @@ import (
|
||||
"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"
|
||||
meshasker "git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -252,9 +255,9 @@ func engineActs(opts options, node string, queue *link.Session, search *accounts
|
||||
})
|
||||
return judgeRootFree(node, lastAccounts.Load(), time.Now(), served, why)
|
||||
},
|
||||
Say: queue.SayEngine}
|
||||
asker := asks.EngineAsker(node)
|
||||
h.Asker = &meshasker.Client{Module: asker, State: state, Publish: queue.PublishAsk,
|
||||
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
|
||||
}
|
||||
@@ -275,3 +278,115 @@ func reconcileActs(ctx context.Context, h *link.HandActs) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -82,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
|
||||
|
||||
+76
-10
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
@@ -10,15 +11,17 @@ import (
|
||||
|
||||
"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)) {
|
||||
e := fillingEngine(opts, signer, say)
|
||||
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 {
|
||||
@@ -30,9 +33,10 @@ func serveFilling(ctx context.Context, opts options, signer ed25519.PublicKey, s
|
||||
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), once a module's apply or removal settled (agent.Env.Settled).
|
||||
// What it told, or why it could not, is said.
|
||||
func knockFilling(module string, say func(string)) {
|
||||
// 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
|
||||
@@ -40,16 +44,19 @@ func knockFilling(module string, say func(string)) {
|
||||
told, err := e.Knock(filling.Socket)
|
||||
switch {
|
||||
case err != nil:
|
||||
say(fmt.Sprintf("after %s settled: %v", module, err))
|
||||
say(fmt.Sprintf("after %s: %v", after, err))
|
||||
case told != "":
|
||||
say(fmt.Sprintf("after %s settled, the provisioner's client is told to fill again: %s", module, 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) {
|
||||
return declaredNow(opts.state, signer)
|
||||
@@ -73,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: "+
|
||||
@@ -93,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-14
@@ -64,27 +64,18 @@ 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.
|
||||
//
|
||||
// From the lease write, heard as the engine hears it (meshTrust), the line has no answerer: the way out is the node
|
||||
// seat's hand-over with the directory's path, which asks the operator on their channel (novox/hq ADR 0563 §5).
|
||||
// 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>"
|
||||
}
|
||||
if t := foundTrust.Load(); t != nil && t.HandedAway() {
|
||||
return fmt.Sprintf("`%s/node-engine.hand-over` with the directory's path, then your yes on your channel", 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)
|
||||
}
|
||||
|
||||
// foundTrust is what the engine trusts, whose lease decides the way out a directory used as found names.
|
||||
var foundTrust atomic.Pointer[meshTrust]
|
||||
|
||||
// withFoundDirectories adds to a statement every directory used as found, as its module's resource of kind
|
||||
// directory, unhealthy: the reason names its owner and mode as they are now, what was declared, and the line
|
||||
// that hands it over. The path is the target, which the controller keeps as evidence.
|
||||
|
||||
@@ -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,29 +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)
|
||||
}
|
||||
// ADR 0563 §5: from the lease write the remedy is the node seat's hand-over, answered on the operator's channel, and
|
||||
// no longer the controller's terminal line; the lease is the one the engine hears.
|
||||
handed := newMeshTrust(nil)
|
||||
handed.handedAway = true
|
||||
foundTrust.Store(handed)
|
||||
if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason,
|
||||
"`laptop/node-engine.hand-over`") || strings.Contains(r.Reason, "nox node hand-over") || strings.Contains(r.Reason, dir) {
|
||||
t.Fatalf("after the lease write the way out is %q", r.Reason)
|
||||
}
|
||||
foundTrust.Store(newMeshTrust(nil))
|
||||
if r := withFoundDirectories(&link.Health{}, foundDirectories(known)).Resources[0]; !strings.Contains(r.Reason,
|
||||
"`nox node hand-over laptop <directory>`") {
|
||||
t.Fatalf("while the controller holds its lease the way out is %q", r.Reason)
|
||||
}
|
||||
foundTrust.Store((*meshTrust)(nil))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ 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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
)
|
||||
|
||||
// owedBus is a link that takes reports and statements and keeps a have bucket in memory.
|
||||
@@ -21,7 +21,6 @@ type owedBus struct {
|
||||
rv map[string]uint64
|
||||
}
|
||||
|
||||
func (b *owedBus) Report(context.Context, string, []byte) error { return nil }
|
||||
func (b *owedBus) Health(context.Context, string, []byte) error { return nil }
|
||||
func (b *owedBus) HaveBind(context.Context) (link.HaveBucket, error) { return b, nil }
|
||||
|
||||
@@ -45,10 +44,10 @@ func (b *owedBus) Set(_ context.Context, key string, value []byte, revision uint
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *owedBus) have(key string) (delivery.Have, bool) {
|
||||
func (b *owedBus) have(key string) (nodeengine.HaveByNodeModuleValue, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
h, err := delivery.DecodeHave(b.kv[key])
|
||||
h, err := nodeengineclient.DecodeHave(b.kv[key])
|
||||
return h, err == nil
|
||||
}
|
||||
|
||||
@@ -67,19 +66,19 @@ func TestAModuleWithNothingLongRunningIsOkWithinALookOfItsApply(t *testing.T) {
|
||||
}
|
||||
fact := link.ModuleFact{Module: "xorg", FP: "src2:" + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("b", 64)}
|
||||
applying := fact
|
||||
applying.Phase = string(delivery.PhaseApplying)
|
||||
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 != delivery.HealthUnknown {
|
||||
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 == delivery.PhaseRunning && h.Health == delivery.HealthOK {
|
||||
if h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
|
||||
@@ -2,9 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
)
|
||||
@@ -26,8 +26,8 @@ func heldBy(windows *apply.Windows) func(time.Time) map[string]bool {
|
||||
// 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, delivery.Composed) bool {
|
||||
return func(ctx context.Context, c delivery.Composed) bool {
|
||||
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
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ func testTheMachineShowsAResourceNotActive(t *testing.T) {
|
||||
{"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(delivery.Composed{Node: "n1", Module: "dnsmasq", Resources: []json.RawMessage{svc, ctr}})
|
||||
d, err := declarationOf(engcomp.Composed{Node: "n1", Module: "dnsmasq", Resources: []json.RawMessage{svc, ctr}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
meshasker "git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
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"
|
||||
@@ -58,16 +58,16 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) {
|
||||
// 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 []asks.Ask
|
||||
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 = &meshasker.Client{Module: asks.EngineAsker("n1"), State: state,
|
||||
h.Asker = &occ.Client{Module: occ.EngineAsker("n1"), State: state,
|
||||
Publish: func(subject string, body any, id string) error {
|
||||
if a, ok := body.(asks.Ask); ok {
|
||||
if a, ok := body.(oc.Ask); ok {
|
||||
asked = append(asked, a)
|
||||
}
|
||||
return nil
|
||||
@@ -78,7 +78,7 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) {
|
||||
if len(waiting) != 1 || waiting[0] != "library:media" || len(asked) != 1 {
|
||||
t.Fatalf("waiting %v asked %d times", waiting, len(asked))
|
||||
}
|
||||
h.Heard(asks.Warrant{Ask: asked[0].ID, Asker: asks.EngineAsker("n1"), Outcome: asks.OutcomeExpired, At: time.Now()})
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+148
-180
@@ -16,6 +16,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
@@ -99,7 +100,7 @@ func versionAt(self, stamped string) string {
|
||||
const usage = `nox-mesh-host — the node-engine
|
||||
|
||||
A daemon: its launcher runs it, and it applies what the mesh declares for this machine. The
|
||||
operator's command is nox (mesh-cli), on any node, from their own account (novox/hq ADR 0272);
|
||||
operator's command is nox (mesh-cli), on any node, from their own account, through the seats' verbs;
|
||||
the subcommands here are for installing a machine, joining it, and rescuing one no mesh can
|
||||
reach, run as root at the machine.
|
||||
|
||||
@@ -109,8 +110,7 @@ reach, run as root at the machine.
|
||||
key make this machine's tunnel key, or read the one it made, and print the public
|
||||
half: what its join token is issued for (novox/hq ADR 0169)
|
||||
enrol --token T join the mesh — through the tunnel when the token was issued for a key
|
||||
overlay take take over the tunnel found here (novox/hq ADR 0105): its key becomes this
|
||||
node's overlay key and the mesh is told, signed; --tunnel <iface> when several are up
|
||||
overlay take refused: the controller that took a rekey ended and no seat takes one now
|
||||
owned what this host has applied and still owns
|
||||
hand-over <dir> hand a directory used as found to the mesh: the next apply gives it its declared
|
||||
owner and mode. The operator's line is nox node hand-over <node> <dir> on the
|
||||
@@ -156,6 +156,17 @@ type options struct {
|
||||
file string
|
||||
// out is where what a command says goes. Stdout, and a buffer under test.
|
||||
out io.Writer
|
||||
// runner is what the node agent's applies run commands through (issue 774, ADR 0594): nil is the machine's own,
|
||||
// apply.ExecRunner; a test gives one that records what an apply asked of the container runtime.
|
||||
runner apply.Runner
|
||||
}
|
||||
|
||||
// run is the runner the node agent's applies use: the options' own, else the machine's.
|
||||
func (o options) run() apply.Runner {
|
||||
if o.runner != nil {
|
||||
return o.runner
|
||||
}
|
||||
return apply.ExecRunner
|
||||
}
|
||||
|
||||
// parseArgs takes the subcommand first, then its flags.
|
||||
@@ -516,7 +527,7 @@ func runApply(ctx context.Context, opts options, d *declaration.Declaration, raw
|
||||
|
||||
// A file given by digest is had before the plan is drawn from it (novox/hq ADR 0486 §1, issue 1695). This command has
|
||||
// no link to the bus, so a file already here that verifies is used, and any other is refused: never written empty.
|
||||
if err := apply.ResolveFileReferences(ctx, d, link.FilesFrom(ctx)); err != nil {
|
||||
if err := apply.ResolveFileReferencesKept(ctx, d, link.FilesFrom(ctx), objectKeep(opts)); err != nil {
|
||||
return fmt.Errorf("a file of this declaration could not be had by its digest: %w", err)
|
||||
}
|
||||
|
||||
@@ -808,162 +819,21 @@ func enrol(ctx context.Context, opts options) error {
|
||||
detected := profile.Detect(ctx, profile.Default(nil), opts.timeout)
|
||||
reported := profileAsReported(detected)
|
||||
|
||||
// Signed with the identity just generated, so the mesh can tell this machine from anyone else
|
||||
// who knows its public key (novox/hq issue 083).
|
||||
proof := mine.Sign(link.EnrolProof(token.Secret, mine.Public, mine.Overlay.Public,
|
||||
sealing.Public, serving.Public))
|
||||
// The token says where to go and which certificate that address must present. It says nothing
|
||||
// about which bus is there, and does not need to: there is one, and this host knows which
|
||||
// (novox/hq ADR 0131 — the mesh speaks to one seat and the old transport is gone).
|
||||
//
|
||||
// **It used to leave this empty** and mean "whatever the mesh runs today", which was true
|
||||
// while two buses existed and became a refusal the moment one did: an empty transport is not
|
||||
// the bus's name, so every enrolment ended at "this token is for the \"\" bus"
|
||||
// (novox/hq 04-ISSUES/146). Nothing caught it because nothing had enrolled since the bus
|
||||
// changed.
|
||||
reply, err := link.Enrol(ctx,
|
||||
link.Approach{Address: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS},
|
||||
*name, token.Secret,
|
||||
mine.Public, mine.Overlay.Public, sealing.Public, serving.Public, reported, proof, found,
|
||||
opts.timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The mesh's name for this node wins over what the machine called itself: the token was
|
||||
// issued for a node record, and that record is what the identity binds to.
|
||||
mine.Node = reply.Node
|
||||
mine.Membership = identity.Membership{
|
||||
Broker: firstNonEmpty(reply.Broker, token.Broker),
|
||||
Fingerprint: firstNonEmpty(reply.Fingerprint, token.Fingerprint),
|
||||
Signer: firstNonEmpty2(reply.Signer, token.Signer),
|
||||
Password: reply.Password,
|
||||
// **Which bus this membership is for, said rather than left empty** (novox/hq
|
||||
// 04-ISSUES/146). The link refuses a membership that names another bus, and an empty name
|
||||
// is not this one's — so a node enrolled without it came up and reconnected for ever
|
||||
// against its own record: "this membership is for \"\", and the mesh's bus is nats". The
|
||||
// reply does not carry it because there is one bus and the host knows which (ADR 0131);
|
||||
// what was missing was writing that down where the link reads it.
|
||||
Transport: link.OnNATS,
|
||||
}
|
||||
if mine.Membership.Password == "" {
|
||||
// The mesh did not replace the token's secret, so it is still this node's broker
|
||||
// password. Said rather than silently kept: a one-time secret living on as a credential
|
||||
// is worth knowing about.
|
||||
mine.Membership.Password = token.Secret
|
||||
fmt.Println("\nnote: the mesh issued no separate broker password, so the token's secret " +
|
||||
"remains this node's credential")
|
||||
}
|
||||
|
||||
// Saved only now, and only once the mesh has said it knows this node. A node holding an
|
||||
// identity the mesh has never recorded would believe it had joined and be believed by
|
||||
// nobody — worse than not having joined, because nothing would look wrong.
|
||||
if err := identity.Save(identityPath, mine); err != nil {
|
||||
return fmt.Errorf(
|
||||
"the mesh accepted this node as %q and its identity could not be saved: %w\n"+
|
||||
"That token is spent, so getting back needs a new one", reply.Node, err)
|
||||
}
|
||||
|
||||
if !mine.Membership.Joined() {
|
||||
return fmt.Errorf(
|
||||
"the mesh accepted this node as %q but did not say how to reach it again, so this "+
|
||||
"identity could not be used after a restart. Nothing was saved", reply.Node)
|
||||
}
|
||||
|
||||
// Written before the identity, so a node that dies between the two has a key file with no
|
||||
// identity — which enrols again cleanly — rather than an identity naming a key that is not
|
||||
// there, which looks joined and cannot come up.
|
||||
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)
|
||||
}
|
||||
|
||||
fmt.Printf("\nenrolled as %s\n", reply.Node)
|
||||
fmt.Printf(" identity %s\n", identityPath)
|
||||
fmt.Printf(" queue %s\n", reply.Queue)
|
||||
return nil
|
||||
// Through the seat mesh-node-enroller (novox/hq ADR 0587 §6), the one way in: the controller's enrolment subject ended
|
||||
// with the controller (issue 1126).
|
||||
return enrolThroughSeat(ctx, opts, token, *name, mine, sealing, serving, reported, found, identityPath)
|
||||
}
|
||||
|
||||
// overlayCommand is `mesh-host overlay take`: this node takes the tunnel found on its machine over
|
||||
// after it enrolled (novox/hq ADR 0105). For a node that enrolled before the mesh knew to take a
|
||||
// tunnel over — re-enrolling would rotate its identity, sealing and serving keys, and with them
|
||||
// every credential the mesh sealed to it.
|
||||
func overlayCommand(ctx context.Context, opts options) error {
|
||||
// overlayCommand is `mesh-host overlay take`: a node taking the tunnel found on its machine over after it enrolled
|
||||
// (novox/hq ADR 0105). Refused, and nothing is changed: the rekey it sent was taken by the controller alone, which
|
||||
// ended by its unassignment (issue 1126), and no seat takes one now; a key written here that no part of the mesh knows
|
||||
// would raise the mesh's interface on a key its hub does not peer.
|
||||
func overlayCommand(_ context.Context, opts options) error {
|
||||
if opts.file != "take" {
|
||||
return errors.New("overlay take [--tunnel <iface>] — the one thing `overlay` does here")
|
||||
}
|
||||
identityPath := identity.Path(opts.state)
|
||||
mine, err := identity.Load(identityPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found, err := tunnel.Find(ctx, apply.ExecRunner, opts.tunnel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w. Nothing was changed", err)
|
||||
}
|
||||
taken, rekey, err := rekeyOnto(mine, found)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("taking over %s: this node's overlay key becomes the tunnel's, %s\n", found, taken.Overlay.Public)
|
||||
fmt.Printf(" identity, sealing and serving keys are untouched\n")
|
||||
|
||||
// Told first, then written: a mesh told and a machine not yet written is put right by running
|
||||
// this again (the mesh refuses the stale second rekey and changes nothing; the files are
|
||||
// rewritten the same). A machine written and a mesh not told would raise the mesh's interface
|
||||
// on a key the mesh does not know at the next restart.
|
||||
if err := link.Publish(ctx, link.Membership{
|
||||
Node: mine.Node, Broker: mine.Membership.Broker, Fingerprint: mine.Membership.Fingerprint,
|
||||
Password: mine.Membership.Password, Signer: mine.Membership.Signer,
|
||||
Transport: mine.Membership.Transport,
|
||||
}, link.Report{Node: mine.Node, Rekey: &rekey}, opts.timeout); err != nil {
|
||||
return fmt.Errorf("the mesh could not be told; nothing was written here: %w", err)
|
||||
}
|
||||
fmt.Printf(" told the mesh signed rekey sent; `node show %s` on the controller says whether it took\n", mine.Node)
|
||||
|
||||
if err := os.WriteFile(identity.OverlayKeyPath(opts.state),
|
||||
[]byte(taken.Overlay.Private+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("the mesh was told and this node's overlay key could not be written: %w — run this again", err)
|
||||
}
|
||||
if err := identity.Save(identityPath, taken); err != nil {
|
||||
return fmt.Errorf("the mesh was told and this node's identity could not be saved: %w — run this again", err)
|
||||
}
|
||||
fmt.Printf(" written %s and the identity; the mesh's interface reads the key when the next push restarts it\n",
|
||||
identity.OverlayKeyPath(opts.state))
|
||||
fmt.Printf(" next on the controller: `overlay place %s --hub --endpoint <host>:%d …`, `plan %s --json`, then push\n",
|
||||
mine.Node, found.Port, mine.Node)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rekeyOnto is the identity with the found tunnel's key as its overlay key, and the signed rekey
|
||||
// that tells the mesh. Pure, so it can be held to: node, sealing and serving keys are the same
|
||||
// bytes in and out; only the overlay key moves. Run again after a take, the previous key it names
|
||||
// is the one before the take, so the mesh can tell a repeat from a replay.
|
||||
func rekeyOnto(mine identity.Identity, found tunnel.Found) (identity.Identity, link.Rekey, error) {
|
||||
overlay, err := identity.OverlayKeyFrom(found.PrivateKey())
|
||||
if err != nil {
|
||||
return identity.Identity{}, link.Rekey{}, err
|
||||
}
|
||||
previous := mine.Overlay.Public
|
||||
if previous == overlay.Public && mine.OverlayBefore != "" {
|
||||
previous = mine.OverlayBefore
|
||||
}
|
||||
taken := mine
|
||||
taken.Overlay = overlay
|
||||
if previous != overlay.Public {
|
||||
taken.OverlayBefore = previous
|
||||
}
|
||||
presented := carried(found)
|
||||
rekey := link.Rekey{Previous: previous, OverlayKey: overlay.Public, Tunnel: presented}
|
||||
rekey.Proof = mine.Sign(link.RekeyProof(mine.Node, previous, overlay.Public, presented))
|
||||
return taken, rekey, nil
|
||||
return errors.New("overlay take is refused: the controller that took a rekey ended (issue 1126) and no seat " +
|
||||
"takes one now. Nothing was changed")
|
||||
}
|
||||
|
||||
// carried is a found tunnel as it is presented to the mesh: everything but its private key.
|
||||
@@ -1066,6 +936,11 @@ func runLink(ctx context.Context, opts options) error {
|
||||
// which the launcher already reads as "run whatever is on disk now".
|
||||
aside, standAside := context.WithCancel(ctx)
|
||||
defer standAside()
|
||||
// **And it ends between applies too** (novox/hq issue 1585): whichever way the link ends, the apply in flight ends and
|
||||
// saves before the process does. Deferred after standAside, so it runs before it: nothing is cancelled under it.
|
||||
defer func() { _ = stopBetweenApplies(say, stopBound) }()
|
||||
// What an earlier stop cut off before its rename is removed, and said, before anything saves.
|
||||
removeLeftoverSaves(opts.state, say)
|
||||
stoodAside := false
|
||||
membership := link.Membership{
|
||||
Node: mine.Node,
|
||||
@@ -1074,6 +949,8 @@ func runLink(ctx context.Context, opts options) error {
|
||||
Password: mine.Membership.Password,
|
||||
Transport: mine.Membership.Transport,
|
||||
Signer: mine.Membership.Signer,
|
||||
// Which of its two logins it dials as (ADR 0583 §1): the one the vault's node-logins names.
|
||||
Logins: loadLogins(opts.state, mine.Node, mine.Membership.Password),
|
||||
}
|
||||
|
||||
// What follows an apply of a module (the node agent's, novox/hq ADR 0539): a membership it delivered for another bus
|
||||
@@ -1105,17 +982,13 @@ func runLink(ctx context.Context, opts options) error {
|
||||
// the bus, answer what the controller asks of this machine, and say its facts: when a link opens, and when the
|
||||
// controller asks.
|
||||
proof := &proving{statePath: opts.state, version: runningVersion(), say: say}
|
||||
// What this engine trusts a mesh signature from: the controller's key until its lease is handed away, and the vault's
|
||||
// once read from its signers (novox/hq ADR 0550 §2).
|
||||
trust := newMeshTrust(mine.Membership.Signer)
|
||||
// And the way out a directory used as found names follows the lease it hears (novox/hq ADR 0563 §5).
|
||||
foundTrust.Store(trust)
|
||||
// What this engine trusts a mesh signature from: the vault's key alone, once read from its signers (novox/hq ADR 0550
|
||||
// §2; issue 1126: the controller's key never, the controller ended by its unassignment).
|
||||
trust := newMeshTrust()
|
||||
queue := &link.Session{
|
||||
Membership: membership,
|
||||
// And the public halves and certificate its record says (novox/hq ADR 0555 §3), read at each facts report.
|
||||
Keys: recordKeys(opts.state, trust.Trusts),
|
||||
// And the controller's key, read per signed ask: none once its lease is handed away (novox/hq ADR 0563 §7).
|
||||
ControllerKey: trust.Controller,
|
||||
// And the first have this build writes is what proves it to the launcher (ADR 0539 §9, ADR 0227 §8).
|
||||
HaveWritten: proof.haveWritten,
|
||||
// And the operator's hand-over of a directory used as found, carried by the controller from the
|
||||
@@ -1141,6 +1014,11 @@ func runLink(ctx context.Context, opts options) error {
|
||||
queue.Acts = engineActs(opts, mine.Node, queue, setuidSearch)
|
||||
go reconcileActs(ctx, queue.Acts)
|
||||
current := &agentNow{}
|
||||
queue.Acts.Composition = compositionFrom(current)
|
||||
queue.Acts.Compose = composeFrom(current, &composerNow)
|
||||
queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow)
|
||||
queue.Acts.SeatList, queue.Acts.SeatListAgain = seatListRead, seatListAgain
|
||||
queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow)
|
||||
setRunsHere(runsFromAgent(current))
|
||||
factsOfNow := func(ctx context.Context, ag *agent.Agent) {
|
||||
sayFacts(ctx, opts, mine.Membership.Signer, queue, say, ag)
|
||||
@@ -1187,6 +1065,22 @@ func runLink(ctx context.Context, opts options) error {
|
||||
// And what it trusts, followed on the bus, said in its record whenever it changes (ADR 0550 §2).
|
||||
trust.onChange(func() { factsOfNow(aside, current.get()) })
|
||||
go followTrust(aside, queue, trust, say)
|
||||
// And the status view's `settled`, for a failed pull from a store to be tried again once the store's holder has settled
|
||||
// (design 51, ENG-11).
|
||||
go followSettled(aside, queue, func() settledTold {
|
||||
if ag := current.get(); ag != nil {
|
||||
return ag
|
||||
}
|
||||
return nil
|
||||
}, say)
|
||||
// And the login it dials as, which the vault's node-logins names for this node (novox/hq ADR 0583 §1).
|
||||
rotator := newRotator(queue, opts.state, opts.timeout, say)
|
||||
go followLogins(aside, queue, mine.Node, func(nl meshvault.NodeLoginsByNodeValue) {
|
||||
if line := membership.Logins.Heard(nl); line != "" {
|
||||
say(line)
|
||||
}
|
||||
rotator.Heard(aside, nl)
|
||||
}, say)
|
||||
go nodeAgent(aside, opts, mine.Membership.Signer, trust, queue, sched, say, afterApply, current, factsOfNow)
|
||||
go busyPasses(aside, func() {
|
||||
if ag := current.get(); ag != nil {
|
||||
@@ -1195,12 +1089,9 @@ func runLink(ctx context.Context, opts options) error {
|
||||
}, BusyAskEvery)
|
||||
// And the core builds this host placed are judged, whichever host placed them (to-be 45 §8).
|
||||
go watchWhatThisHostPlaced(aside, mine.Node, queue, say)
|
||||
// And mesh-cli is answered on this machine: the account from the kernel, the line asked of the controller
|
||||
// (novox/hq ADR 0272).
|
||||
go serveMeshCLI(aside, queue, say)
|
||||
// And a filled file is written on the provisioner client's request, one at a time with the apply (novox/hq ADR
|
||||
// 0349 §4).
|
||||
go serveFilling(aside, opts, mine.Membership.Signer, say)
|
||||
go serveFilling(aside, opts, mine.Membership.Signer, queue, mine.Node, say)
|
||||
|
||||
held := link.HoldRoused(aside, membership, queue, say, opts.timeout, rousedBySignal(ctx))
|
||||
// **Cleanly**, or the launcher counts standing aside as a crash and rolls the new host back
|
||||
@@ -1218,8 +1109,6 @@ func runLink(ctx context.Context, opts options) error {
|
||||
// be a control surface on every machine, reachable by anything that can reach the machine, in
|
||||
// exchange for saving twenty seconds — and the whole security argument rests on there not being
|
||||
// one. A signal is delivered by the service manager to a process it already supervises.
|
||||
// (mesh-cli's socket is no such surface: it has no network address and nothing sent to it acts on
|
||||
// the machine — it is asked of the controller, novox/hq ADR 0272 §3.)
|
||||
//
|
||||
// SIGHUP, because that is the signal a long-running program conventionally reads as *look again*,
|
||||
// and nothing here is being reloaded from a file that a different signal would suit better.
|
||||
@@ -1353,6 +1242,8 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Session,
|
||||
var lastSaid time.Time
|
||||
owed := true
|
||||
spaced := 1.0
|
||||
// healthUnwritten is why the health report was last not written, said once each time it changes.
|
||||
var healthUnwritten string
|
||||
// A module whose change ended unjudged is looked at and said at once (review of fix 15): a module with nothing
|
||||
// long-running is then ok within a look of its apply, not at the five-minute statement.
|
||||
var nudged <-chan struct{}
|
||||
@@ -1436,6 +1327,11 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Session,
|
||||
healthy := st.Healthy() && (netSt == nil || netSt.State != network.Unhealthy) &&
|
||||
(unitSt == nil || len(unitSt.Failed) == 0) && (accountSt == nil || accountSt.Healthy()) && len(found) == 0 &&
|
||||
accessesHealthy(accessSt)
|
||||
h := *withAccesses(withFoundDirectories(withAccounts(withUnits(healthAsReported(st, netSt),
|
||||
unitSt), accountSt), found), accessSt)
|
||||
// The node's own health report in its key on the bus, compared on every look and written only when what it says
|
||||
// changed (issue 774): what the watchman raises the machine's alerts from, without the controller.
|
||||
writeHealthReport(ctx, queue, h, netSt, &healthUnwritten, say)
|
||||
if !owed && !(!healthy && since >= sayUnhealthyAgain) && since < sayAnyway {
|
||||
continue
|
||||
}
|
||||
@@ -1462,13 +1358,40 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Session,
|
||||
reconcile(inactive...)
|
||||
}
|
||||
}
|
||||
if queue.SayHealth(ctx, *withAccesses(withFoundDirectories(withAccounts(withUnits(healthAsReported(st, netSt),
|
||||
unitSt), accountSt), found), accessSt)) {
|
||||
if queue.SayHealth(ctx, h) {
|
||||
lastSaid, owed = time.Now(), false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeHealthReport writes this node's health report to its key (link.WriteNodeHealth) when it changed. Why it could
|
||||
// not is said once per reason, and said again when it is written after all.
|
||||
func writeHealthReport(ctx context.Context, queue *link.Session, h link.Health, ns *network.Statement, unwritten *string,
|
||||
say link.Announce) {
|
||||
if queue == nil {
|
||||
return
|
||||
}
|
||||
var peers []link.PeerSeen
|
||||
if ns != nil {
|
||||
for _, p := range ns.Peers {
|
||||
peers = append(peers, link.PeerSeen{Key: p.Key, Addresses: p.Addresses, Seen: p.Seen})
|
||||
}
|
||||
}
|
||||
var machines link.Machines
|
||||
if c := machinesNow.Load(); c != nil {
|
||||
machines.ByAddress, machines.Hub = c.Machines()
|
||||
}
|
||||
_, err := queue.WriteNodeHealth(ctx, link.NodeHealthOf(queue.Membership.Node, h, peers, machines))
|
||||
switch {
|
||||
case err != nil && err.Error() != *unwritten:
|
||||
*unwritten = err.Error()
|
||||
say("this machine's health report is not written to the bus: " + err.Error())
|
||||
case err == nil && *unwritten != "":
|
||||
*unwritten = ""
|
||||
say("this machine's health report is written to the bus again")
|
||||
}
|
||||
}
|
||||
|
||||
// healthAsReported is a statement as the report and the event carry it.
|
||||
func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health {
|
||||
// LibraryContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase B), judges a
|
||||
@@ -1490,7 +1413,7 @@ func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health
|
||||
h.Network = &link.NetworkHealth{State: ns.State, Since: ns.Since.UTC(), Parts: []link.NetworkPart{}}
|
||||
for _, p := range ns.Parts {
|
||||
h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason,
|
||||
Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: p.Streak})
|
||||
Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: int64(p.Streak)})
|
||||
}
|
||||
}
|
||||
return h
|
||||
@@ -1664,6 +1587,15 @@ func announceOr(say link.Announce) func(string) {
|
||||
// run by hand beside this service — the lock beside the state does the same (store.Lock).
|
||||
var applying sync.Mutex
|
||||
|
||||
// objectKeep is the node's own keep of the objects its compositions name by digest, beside its state (apply.ObjectKeep,
|
||||
// issue 924): a composition the node once applied never again needs the controller's bucket to hold its files.
|
||||
func objectKeep(opts options) *apply.ObjectKeep {
|
||||
if opts.state == "" {
|
||||
return nil
|
||||
}
|
||||
return apply.KeepObjectsIn(filepath.Join(filepath.Dir(opts.state), "objects"))
|
||||
}
|
||||
|
||||
// appliedModule is how the apply of one module's declaration came out.
|
||||
type appliedModule struct {
|
||||
// Refused is why nothing was applied, when nothing was.
|
||||
@@ -1703,7 +1635,7 @@ func applyModuleHeld(ctx context.Context, opts options, declared *declaration.De
|
||||
|
||||
// **A file given by digest is fetched and verified before anything of the composition is applied** (novox/hq ADR
|
||||
// 0486 §1, issue 1695): one object missing or differing refuses the module whole.
|
||||
if err := apply.ResolveFileReferences(ctx, declared, link.FilesFrom(ctx)); err != nil {
|
||||
if err := apply.ResolveFileReferencesKept(ctx, declared, link.FilesFrom(ctx), objectKeep(opts)); err != nil {
|
||||
return appliedModule{Refused: "a file of this composition could not be had by its digest: " + err.Error()}
|
||||
}
|
||||
|
||||
@@ -1730,11 +1662,11 @@ func applyModuleHeld(ctx context.Context, opts options, declared *declaration.De
|
||||
func(known store.State) (apply.Report, store.State, error) {
|
||||
if scope == nil {
|
||||
return apply.ApplyMindingWindows(ctx, built, declared, known, store.OriginDeclared,
|
||||
apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
|
||||
opts.run(), announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
|
||||
apply.WindowsIn(filepath.Dir(opts.state)))
|
||||
}
|
||||
return apply.ApplyModule(ctx, built, declared, *scope, known, store.OriginDeclared,
|
||||
apply.ExecRunner, announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
|
||||
opts.run(), announceOr(say), sealOpener(opts.state), apply.KeepIn(filepath.Dir(opts.state)),
|
||||
apply.WindowsIn(filepath.Dir(opts.state)))
|
||||
}, announceOr(say))
|
||||
|
||||
@@ -1797,9 +1729,8 @@ func factsOf(ctx context.Context, opts options, declared *declaration.Declaratio
|
||||
if j := judging; j != nil {
|
||||
// A container that waits for a library whose disk or mount is not there was not created: the library says
|
||||
// why, and liveness judges it neither alive nor dead (novox/hq ADR 0328 §9).
|
||||
j.Set(liveness.LongRunning(declared, withWaiting(held, apply.WaitingOnLibrary(declared))))
|
||||
judgeWhatIsDeclared(declared, held)
|
||||
st, _ := j.Look(ctx)
|
||||
netJudge.declare(declaredResolvConf(declared))
|
||||
var netSt *network.Statement
|
||||
if n := netJudge.get(); n != nil {
|
||||
ns := n.Last()
|
||||
@@ -1807,7 +1738,6 @@ func factsOf(ctx context.Context, opts options, declared *declaration.Declaratio
|
||||
}
|
||||
var unitSt *units.Statement
|
||||
if u := unitJudge; u != nil {
|
||||
u.Set(units.OwnedBy(declared, held))
|
||||
us := u.Last()
|
||||
unitSt = &us
|
||||
}
|
||||
@@ -1815,11 +1745,9 @@ func factsOf(ctx context.Context, opts options, declared *declaration.Declaratio
|
||||
// said with it, relogin needed included, in the report that says the apply.
|
||||
var accountSt *accounts.Statement
|
||||
if a := accountJudge; a != nil {
|
||||
a.Set(accounts.Of(declared, held))
|
||||
as, _ := a.Look(ctx)
|
||||
accountSt = &as
|
||||
}
|
||||
accesses.set(declared, held)
|
||||
// A take act stopped at its bound goes on, beside (ADR 0328 §7).
|
||||
keeping.poke(ctx)
|
||||
accessSt, _ := accesses.look(time.Now())
|
||||
@@ -1914,6 +1842,46 @@ func factsOf(ctx context.Context, opts options, declared *declaration.Declaratio
|
||||
return report
|
||||
}
|
||||
|
||||
// judgeWhatIsDeclared hands every judge of the health loop what is declared here now: the long-running resources, the
|
||||
// resolver file, the units, the accounts and the libraries. A resource, unit, account or library no longer declared is
|
||||
// forgotten by its judge, so a module whose composition drops one is no longer watched for it.
|
||||
func judgeWhatIsDeclared(declared *declaration.Declaration, held map[string]bool) {
|
||||
if j := judging; j != nil {
|
||||
// A container that waits for a library whose disk or mount is not there was not created: the library says
|
||||
// why, and liveness judges it neither alive nor dead (novox/hq ADR 0328 §9).
|
||||
j.Set(liveness.LongRunning(declared, withWaiting(held, apply.WaitingOnLibrary(declared))))
|
||||
}
|
||||
netJudge.declare(declaredResolvConf(declared))
|
||||
if u := unitJudge; u != nil {
|
||||
u.Set(units.OwnedBy(declared, held))
|
||||
}
|
||||
if a := accountJudge; a != nil {
|
||||
a.Set(accounts.Of(declared, held))
|
||||
}
|
||||
accesses.set(declared, held)
|
||||
}
|
||||
|
||||
// judgeWhatRunsNow hands the judges what runs now, as the node agent holds it once a module settled (applied or
|
||||
// removed) or was taken as running: the facts said inside an apply read the module's composition from before it, so a
|
||||
// resource its new composition dropped stayed watched until the next facts (g14 and shanks, 2026-10-30 16:14: restic's
|
||||
// process restic.measure, removed, then said down every look).
|
||||
func judgeWhatRunsNow(statePath string, signer ed25519.PublicKey) {
|
||||
if judging == nil {
|
||||
return
|
||||
}
|
||||
declared, err := declaredNow(statePath, signer)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
held := map[string]bool{}
|
||||
if known, err := store.Load(statePath); err == nil {
|
||||
for _, h := range known.Held {
|
||||
held[h.ID] = true
|
||||
}
|
||||
}
|
||||
judgeWhatIsDeclared(declared, held)
|
||||
}
|
||||
|
||||
// sayFacts says this node's facts on the link open now (novox/hq ADR 0539 §9): what the node is made of, how what runs
|
||||
// here is, and its filters and ports. What runs is the compositions the node agent holds running (ag), or, before it
|
||||
// has run, the declaration the node kept before the switch. It applies nothing; the haves are the agent's.
|
||||
@@ -2065,7 +2033,7 @@ func adoptDeliveredMembership(identityPath string, mine *identity.Identity, say
|
||||
return
|
||||
}
|
||||
say(fmt.Sprintf("moving to the %s bus at %s — restarting to dial it", next.Transport, next.Broker))
|
||||
os.Exit(0)
|
||||
exitBetweenApplies(say)
|
||||
}
|
||||
|
||||
// profileAsReported is the profile as the mesh reads it — the same bytes enrolment sends, so a
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -132,6 +133,57 @@ func TestAFlagAfterAPositionalIsRead(t *testing.T) {
|
||||
// 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")
|
||||
|
||||
@@ -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.Session, 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.Session) 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"
|
||||
@@ -40,6 +41,11 @@ func moveSecretFrom(statePath string, queue *link.Session) 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 = "", ""
|
||||
|
||||
+279
-229
@@ -3,10 +3,13 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -15,12 +18,11 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/compose/frombus"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"github.com/novox/mesh-host/internal/selfcompose"
|
||||
@@ -44,7 +46,7 @@ func (w bucketWatch) Watch(ctx context.Context, pattern string) (<-chan agent.Up
|
||||
return w.updates(ctx, in), nil
|
||||
}
|
||||
|
||||
// WatchAwait is Watch for a state that its holder makes when it starts (the placement seat's): a bucket not on the bus
|
||||
// WatchAwait is Watch for a state that its holder makes when it starts (the assignment seat's): a bucket not on the bus
|
||||
// yet is waited for on the bus's word that it was created (link.Session.KVWatchAwait), and waiting is told why.
|
||||
func (w bucketWatch) WatchAwait(ctx context.Context, pattern string, waiting func(error)) (<-chan agent.Update, error) {
|
||||
in, err := w.session.KVWatchAwait(ctx, w.bucket, pattern, waiting)
|
||||
@@ -85,7 +87,7 @@ func (h sessionHaves) Read(ctx context.Context, module string) (agent.Have, bool
|
||||
if !ok {
|
||||
return agent.Have{}, false
|
||||
}
|
||||
return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
|
||||
return agent.Have{Module: module, FP: got.Fp, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
|
||||
Sealed: got.SealedAt}, true
|
||||
}
|
||||
|
||||
@@ -103,25 +105,25 @@ func (h sessionHaves) ReadErr(ctx context.Context, module string) (agent.Have, b
|
||||
if !ok || err != nil {
|
||||
return agent.Have{}, false, err
|
||||
}
|
||||
return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
|
||||
return agent.Have{Module: module, FP: got.Fp, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
|
||||
Sealed: got.SealedAt}, true, nil
|
||||
}
|
||||
|
||||
func (h sessionHaves) Write(ctx context.Context, x agent.Have) {
|
||||
if x.Module == agent.NodeModule {
|
||||
// The node's own key holds its facts and the digest of the composition it runs.
|
||||
if x.Phase == "" || x.Phase == string(delivery.PhaseRunning) {
|
||||
if x.Phase == "" || x.Phase == string(nodeengineclient.PhaseRunning) {
|
||||
h.session.NoteNode(ctx, x.Composed, x.Sealed)
|
||||
}
|
||||
return
|
||||
}
|
||||
phase := x.Phase
|
||||
if phase == string(delivery.PhaseRunning) {
|
||||
if phase == string(nodeengineclient.PhaseRunning) {
|
||||
phase = ""
|
||||
}
|
||||
h.session.NoteModule(ctx, link.ModuleFact{Module: x.Module, FP: x.FP, Composed: x.Composed, Phase: phase,
|
||||
Failed: phase == string(delivery.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits,
|
||||
Sealed: x.Sealed})
|
||||
Failed: phase == string(nodeengineclient.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits,
|
||||
BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed, PullWait: x.PullWait})
|
||||
}
|
||||
|
||||
func (h sessionHaves) Removed(ctx context.Context, module string) {
|
||||
@@ -165,19 +167,54 @@ func backupNode(session *link.Session) func(ctx context.Context, module string)
|
||||
defer stop()
|
||||
// A module this machine backs nothing of up moves without a backup: never held for one that cannot run (issue
|
||||
// 1945). An answer that cannot be had or read asks for the backup as before.
|
||||
if said, err := session.AskSeat(ctx, "node-backup", "backed-up", map[string]any{"module": module}, 30*time.Second); err == nil {
|
||||
ask := func(verb string) func(context.Context) (json.RawMessage, error) {
|
||||
return func(ctx context.Context) (json.RawMessage, error) {
|
||||
return session.AskSeat(ctx, "node-backup", verb, map[string]any{"module": module}, 30*time.Second)
|
||||
}
|
||||
}
|
||||
if said, err := askUntilHeard(ctx, ask("backed-up"), backupAskFor, link.Backoff); err == nil {
|
||||
if nothing, read := declaresNothingToBackUp(said, module); read && nothing {
|
||||
return agent.Backup{Done: true, Words: module + " declares nothing to back up on this machine"}
|
||||
}
|
||||
}
|
||||
asked := time.Now()
|
||||
if _, err := session.AskSeat(ctx, "node-backup", "now", map[string]any{"module": module}, 30*time.Second); err != nil {
|
||||
if _, err := askUntilHeard(ctx, ask("now"), backupAskFor, link.Backoff); err != nil {
|
||||
return agent.Backup{Words: err.Error()}
|
||||
}
|
||||
return awaitBackup(ctx, events, session.Membership.Node, module, asked, backupWithin)
|
||||
}
|
||||
}
|
||||
|
||||
// backupAskFor is how long the backup's ask is asked again while it is known never to have reached the holder of
|
||||
// node-backup: nothing on this machine answers it (the tool runner restarting, its subscriptions not made yet) or no
|
||||
// link is open. The sweep of 2026-10-30 met it on ace's redis, postgres and restic (14:03), step-ca (13:55) and
|
||||
// mesh-vault on novox (15:22): each was held on one unanswered ask. Past the bound the backup failed, said with how
|
||||
// often and how long it was asked: the module is held, its have says so, and the assigner asks the operator.
|
||||
var backupAskFor = 3 * time.Minute
|
||||
|
||||
// askUntilHeard asks, and asks again after wait(try) while the answer says the ask was never delivered (link.ErrNotAsked),
|
||||
// for at most within: an ask that may have reached its holder (a timeout, a refusal in words) is never asked twice, so a
|
||||
// backup is never started twice.
|
||||
func askUntilHeard(ctx context.Context, ask func(context.Context) (json.RawMessage, error), within time.Duration,
|
||||
wait func(int) time.Duration) (json.RawMessage, error) {
|
||||
began := time.Now()
|
||||
for try := 1; ; try++ {
|
||||
said, err := ask(ctx)
|
||||
if err == nil || !errors.Is(err, link.ErrNotAsked) {
|
||||
return said, err
|
||||
}
|
||||
pause := wait(try)
|
||||
if time.Since(began)+pause > within {
|
||||
return nil, fmt.Errorf("asked %d times over %s and never heard: %w", try, time.Since(began).Round(time.Second), err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("the node agent was stopped while it asked again: %w", err)
|
||||
case <-time.After(pause):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// awaitBackup waits for the event that says the backup of a module on this node ended, within the bound: done when it
|
||||
// says ok, held on its words when it does not, and held when it says nothing within the bound. An event of another node
|
||||
// or module, or from before the ask, is not the answer.
|
||||
@@ -187,7 +224,7 @@ func awaitBackup(ctx context.Context, events <-chan []byte, node, module string,
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return agent.Backup{Words: "the node agent was stopped while the backup ran"}
|
||||
return agent.Backup{Words: agent.BackupInterrupted, Interrupted: true}
|
||||
case <-bound.C:
|
||||
return agent.Backup{Words: fmt.Sprintf("the backup did not say it was done within %s", within)}
|
||||
case raw := <-events:
|
||||
@@ -221,16 +258,16 @@ func awaitBackup(ctx context.Context, events <-chan []byte, node, module string,
|
||||
// the controller put modules it knew no fingerprint of the new form for into the node's own composition while their
|
||||
// wants named their own, and the declaration joined of both declared each such file twice, which the filling refuses
|
||||
// ("2 resources declare X") on every node. The node's own copy is left out, and which were left out is said once.
|
||||
func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) {
|
||||
func declarationOf(cs ...compositions.Composed) (*declaration.Declaration, error) {
|
||||
d := &declaration.Declaration{Version: declaration.Version}
|
||||
var modules []delivery.Composed
|
||||
var modules []compositions.Composed
|
||||
for _, c := range cs {
|
||||
if c.Module != "" {
|
||||
modules = append(modules, c)
|
||||
}
|
||||
}
|
||||
if len(modules) > 0 {
|
||||
joined := make([]delivery.Composed, 0, len(cs))
|
||||
joined := make([]compositions.Composed, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
if c.Module == "" {
|
||||
var dropped []string
|
||||
@@ -265,7 +302,7 @@ func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) {
|
||||
// the same kind and id. A resource that shares only its target (a unit or container of the same name, another id) is
|
||||
// not the module's, and stays: dropped, its record would be an orphan the apply removes, stopping the module's unit. It
|
||||
// answers what it left out, as "<id> (<module>)".
|
||||
func ownWithout(own delivery.Composed, modules []delivery.Composed) (delivery.Composed, []string) {
|
||||
func ownWithout(own compositions.Composed, modules []compositions.Composed) (compositions.Composed, []string) {
|
||||
byID := map[string]string{}
|
||||
for _, c := range modules {
|
||||
if c.Module == "" {
|
||||
@@ -321,14 +358,14 @@ func sayDroppedOnce(dropped []string) {
|
||||
"declares, and they are left out of it: %s (issue 1945)\n", len(dropped), key)
|
||||
}
|
||||
|
||||
func scopeName(c delivery.Composed) string {
|
||||
func scopeName(c compositions.Composed) string {
|
||||
if c.Module == "" {
|
||||
return agent.NodeModule
|
||||
}
|
||||
return c.Module
|
||||
}
|
||||
|
||||
func resourcesOf(cs []delivery.Composed) []declaration.Resource {
|
||||
func resourcesOf(cs []compositions.Composed) []declaration.Resource {
|
||||
var out []declaration.Resource
|
||||
for _, c := range cs {
|
||||
for _, raw := range c.Resources {
|
||||
@@ -340,41 +377,6 @@ func resourcesOf(cs []delivery.Composed) []declaration.Resource {
|
||||
return out
|
||||
}
|
||||
|
||||
// operatorKeysFile is the file beside the node's state that holds the operator's public keys, one ed25519 key in base64 a
|
||||
// line (a line that starts with # is a note). The switch's last declaration writes it as a file resource, beside the
|
||||
// controller's key the node was enrolled with (novox/hq ADR 0539 §3, §11); a node adopted later has it at its adoption.
|
||||
const operatorKeysFile = "operator-keys"
|
||||
|
||||
// trustedKeys are the keys a composition may be signed by: the controller's, which the node was enrolled with, while the
|
||||
// controller holds its lease (ADR 0550 §2: trusted no more once it is handed away), and the operator's, read from
|
||||
// operatorKeysFile each time it is asked, so a key that arrives while the agent runs is trusted at the next composition.
|
||||
// A file that cannot be read trusts the controller's alone, and says so.
|
||||
func trustedKeys(opts options, controllerNow func() ed25519.PublicKey) func() []ed25519.PublicKey {
|
||||
return func() []ed25519.PublicKey {
|
||||
keys := []ed25519.PublicKey{}
|
||||
if controller := controllerNow(); len(controller) == ed25519.PublicKeySize {
|
||||
keys = append(keys, controller)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(filepath.Dir(opts.state), operatorKeysFile))
|
||||
if err != nil {
|
||||
return keys
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(line)
|
||||
if err != nil || len(key) != ed25519.PublicKeySize {
|
||||
fmt.Fprintf(os.Stderr, "mesh-host: a line of %s is not an ed25519 public key in base64, and is not trusted\n", operatorKeysFile)
|
||||
continue
|
||||
}
|
||||
keys = append(keys, ed25519.PublicKey(key))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
}
|
||||
|
||||
// keptOnce reads the kept declaration once and splits it by module: its resources (the node's own are those that say
|
||||
// none, ADR 0539 §9) and its filled paths. Once the read is retired (keptRetired) nothing is read.
|
||||
func keptOnce(opts options, signer ed25519.PublicKey) func() (map[string][]json.RawMessage, map[string][]string) {
|
||||
@@ -445,31 +447,45 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
}
|
||||
began := time.Now()
|
||||
node := session.Membership.Node
|
||||
composedBucket, err := delivery.ComposedBucketFor(node)
|
||||
composedBucket, err := compositions.BucketFor(node)
|
||||
if err != nil {
|
||||
cancel()
|
||||
say("the node agent cannot name this node's composition bucket: " + err.Error())
|
||||
return
|
||||
}
|
||||
var ag *agent.Agent
|
||||
// The kept release-5 declaration is no longer read once every module the seat's placement puts here has a have of its
|
||||
// The kept release-5 declaration is no longer read once every module the seat's assignment puts here has a have of its
|
||||
// own (ADR 0550 §2: its signature is the controller's, trusted no more after the lease write).
|
||||
if retired, why := placedAllHave(linked, session, node); retired && !keptRetired.Swap(true) {
|
||||
say("the kept declaration is no longer read: " + why)
|
||||
// Nor before this engine keeps the node's own as it runs (applied/node.json): until then the kept declaration is the one
|
||||
// record of what the node's own composition carries, its folded modules among them (issue 774, review of the
|
||||
// re-land, round 1: novox's first start would compose its own with nats running nothing).
|
||||
if retired, why := placedAllHave(linked, session, node); retired && !keptRetired.Load() {
|
||||
if !appliedStoreFor(opts.state).holds(agent.NodeModule) {
|
||||
say("the kept declaration is still read: " + why + ", but this engine keeps no node's own composition yet")
|
||||
} else if !keptRetired.Swap(true) {
|
||||
say("the kept declaration is no longer read: " + why)
|
||||
}
|
||||
}
|
||||
kept := keptOnce(opts, signer)
|
||||
// The mesh's own certificate the declaration kept from before carries, kept for the record before this machine
|
||||
// composes its own (ADR 0555 §3): without it a module that asks for a certificate would be stood in for.
|
||||
if byModule, _ := kept(); byModule != nil {
|
||||
for module, resources := range byModule {
|
||||
if err := keepAuthority(opts.state, delivery.Composed{Module: module, Resources: resources}); err != nil {
|
||||
if err := keepAuthority(opts.state, compositions.Composed{Module: module, Resources: resources}); err != nil {
|
||||
say("the mesh's certificate the kept declaration carries could not be kept for this machine's record: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
// The bus's user list, written alone from the newest composition of the bus module (ADR 0547 §8): on every
|
||||
// node, and a node whose compositions carry no list writes nothing.
|
||||
users := userListFollower(opts, session, composedBucket, trustedKeys(opts, trust.Controller), say, func() {
|
||||
// The bus's user list, the grants part's alone (ADR 0547 §8, clause 11; issue 1126), written into the file the
|
||||
// bus module this node runs declares, as its agent holds what runs (its own composition, or the declaration kept
|
||||
// before the switch): never read from the node's composition bucket, which the controller wrote. On every node;
|
||||
// a node that runs no list writes nothing. Looked at again whenever what runs changed (RunsChanged, Settled).
|
||||
users := userListFollower(opts, session, func() []compositions.Composed {
|
||||
if now := current.get(); now != nil {
|
||||
return now.Everything()
|
||||
}
|
||||
return nil
|
||||
}, say, func() {
|
||||
// This engine wrote the list, so the bus reloads its accounts and answers no JetStream request for a few
|
||||
// seconds: what failed in them is tried again once that is over, not at its next wait.
|
||||
time.AfterFunc(reloadSettles, func() {
|
||||
@@ -478,8 +494,6 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
}
|
||||
})
|
||||
})
|
||||
// A vault key heard (or heard again changed) looks at the list again, so its hashes are handed (ADR 0547 §9).
|
||||
vaultKey.following(users.Again)
|
||||
go func() {
|
||||
if err := users.Run(linked); err != nil && linked.Err() == nil {
|
||||
say("the bus's user list is not followed on this link: " + err.Error())
|
||||
@@ -487,14 +501,23 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
}()
|
||||
env := agent.Env{
|
||||
Node: node,
|
||||
Wants: bucketWatch{session, delivery.AssignerWantsBucket},
|
||||
Wants: bucketWatch{session, buckets.Wants},
|
||||
Composed: bucketWatch{session, composedBucket},
|
||||
Haves: sessionHaves{session},
|
||||
Kept: func() map[string][]json.RawMessage { by, _ := kept(); return by },
|
||||
// The same kept declaration's filled paths, so a module still running from it keeps its files filled.
|
||||
KeptFilled: func() map[string][]string { _, filled := kept(); return filled },
|
||||
Recorded: recordedIn(opts.state, say),
|
||||
Inactive: inactiveIn(&liveness.Exec{Run: apply.ExecRunner, System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))),
|
||||
// What this engine applied, kept on the machine and read at the start, so what runs is known before the node
|
||||
// composes its own (issue 774).
|
||||
Applied: appliedStoreFor(opts.state),
|
||||
Inactive: inactiveIn(&liveness.Exec{Run: liveness.Runner(opts.run()), System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))),
|
||||
Held: func() map[string]string {
|
||||
if c := composerNow.Load(); c != nil {
|
||||
return c.LastHeld()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Revisions: func(ctx context.Context, key string) ([]agent.Update, error) {
|
||||
got, err := session.KVHistory(ctx, composedBucket, key)
|
||||
if err != nil {
|
||||
@@ -506,15 +529,25 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
Trusted: trustedKeys(opts, trust.Controller),
|
||||
Backup: backupNode(session),
|
||||
Backup: backupNode(session),
|
||||
// What the filling refused before this apply may be named now: the provisioner's client is told to fill
|
||||
// again (ace, 2026-10-25 21:17).
|
||||
Settled: func(module string) { knockFilling(module, say) },
|
||||
Say: say,
|
||||
Settled: func(module string) {
|
||||
knockFilling(module+" settled", say)
|
||||
judgeWhatRunsNow(opts.state, signer)
|
||||
users.RunsChanged()
|
||||
},
|
||||
// And once the haves are read at the start, and whenever a module is taken as running without an apply, so
|
||||
// a refusal from the engine's start does not wait for some module to settle (issue 924).
|
||||
RunsChanged: func(why string) {
|
||||
knockFilling(why, say)
|
||||
judgeWhatRunsNow(opts.state, signer)
|
||||
users.RunsChanged()
|
||||
},
|
||||
Say: say,
|
||||
// A failure of the bus not answering for a moment is tried again: 2 s, 5 s, 15 s, 60 s, then every 5 min.
|
||||
Backoff: link.Backoff,
|
||||
Apply: func(ctx context.Context, c delivery.Composed, others []delivery.Composed) agent.Result {
|
||||
Apply: func(ctx context.Context, c compositions.Composed, others []compositions.Composed) agent.Result {
|
||||
actx, done := applyContext(session.WithLink(ctx))
|
||||
// While the grants part's list is followed, the controller's list in the bus module's composition is not
|
||||
// written: the apply keeps the list on the machine (ADR 0547 clause 8, never both).
|
||||
@@ -523,7 +556,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
if herr != nil {
|
||||
res = failedWith(herr.Error())
|
||||
} else {
|
||||
res = applyComposition(actx, opts, held, others, sched, say)
|
||||
res = applyComposition(actx, opts, held, others, ag.Placed(scopeName(c)), ag.RemovedByWant(), sched, say)
|
||||
}
|
||||
done()
|
||||
// The mesh's own certificate it carries is kept for this machine's record (ADR 0555 §3).
|
||||
@@ -540,34 +573,44 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
sayFacts(ctx, ag)
|
||||
return res
|
||||
},
|
||||
Remove: func(ctx context.Context, module string, others []delivery.Composed) agent.Result {
|
||||
Remove: func(ctx context.Context, module string, others []compositions.Composed) agent.Result {
|
||||
actx, done := applyContext(session.WithLink(ctx))
|
||||
res := removeComposition(actx, opts, module, others, sched, say)
|
||||
res := removeComposition(actx, opts, module, others, ag.Placed(module), sched, say)
|
||||
done()
|
||||
afterApply()
|
||||
sayFacts(ctx, ag)
|
||||
return res
|
||||
},
|
||||
}
|
||||
// The node composes its own modules once the catalogue places it (novox/hq ADR 0547 §2, ADR 0549, issue 1126): its
|
||||
// placement key is read at the agent's start; until then it applies the controller's compositions as before.
|
||||
// The node composes its own modules (novox/hq ADR 0547 §2, ADR 0549, issue 1126).
|
||||
run, stopRun := context.WithCancel(linked)
|
||||
// What the node runs and how it opens a sealed value, for the composer to keep the boxes it runs; and the agent
|
||||
// told when the node cannot compose, so each wanted module's have says it.
|
||||
// The agent of this run alone: its composer tells it, never the agent of a run begun since (issue 1126: a pass in
|
||||
// flight at the switch marked the next agent's modules failed).
|
||||
own := &agentNow{}
|
||||
// And the seat list it reads through the seat mesh-catalog, from its bootstrapped definitions until it has: the
|
||||
// composer hands nothing new meanwhile, and the node says it has waited five minutes (novox/hq ADR 0636 §1).
|
||||
seats, err := startSeatList(run, session, node, say)
|
||||
if err != nil {
|
||||
// No reader is no gate that lets everything through: the node acts on nothing new, as one that waits does.
|
||||
say(err.Error() + "; the node composes nothing new until it is started again and reads the list (novox/hq ADR 0636 §1)")
|
||||
}
|
||||
// The marks of the haves run off the composition pass, each bounded (see marker).
|
||||
marks := newMarker(run)
|
||||
hooks := composerHooks{
|
||||
running: func() []delivery.Composed {
|
||||
seatList: seats,
|
||||
seatListFailed: err != nil,
|
||||
running: func() []compositions.Composed {
|
||||
if now := own.get(); now != nil {
|
||||
return now.Everything()
|
||||
return runsAsWritten(now.Everything(), opts.state)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
unseal: sealOpener(opts.state),
|
||||
// And what each module's have says it runs sealed: after a restart the agent has applied nothing yet.
|
||||
// A have the bus did not answer for is said as such, never as one holding no seals (issue 1126, 2026-10-28).
|
||||
held: func(module string) (delivery.Seals, []string, error) {
|
||||
held: func(module string) (compositions.Seals, []string, error) {
|
||||
if module == agent.NodeModule {
|
||||
got, _, err := session.ReadNodeHaveErr(linked)
|
||||
return got.SealedAt, got.Sealed, err
|
||||
@@ -580,7 +623,14 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
},
|
||||
refused: func(why string, held map[string]string) {
|
||||
if now := own.get(); now != nil && run.Err() == nil {
|
||||
now.NotComposedEach(run, why, held) // a module held alone marks that module, never the others (issue 1126)
|
||||
// A module held alone marks that module, never the others (issue 1126); the marking never holds the pass.
|
||||
marks.set("refused", func(ctx context.Context) { now.NotComposedEach(ctx, why, held) })
|
||||
}
|
||||
},
|
||||
// And what it keeps of a module as it runs, for a manifest that does not say it yet: on the module's have (ADR 0601 §4).
|
||||
kept: func(kept map[string][]string) {
|
||||
if now := own.get(); now != nil && run.Err() == nil {
|
||||
marks.set("kept", func(ctx context.Context) { now.KeptNow(ctx, kept) })
|
||||
}
|
||||
},
|
||||
// And the vault's authority beside the controller's, and this node's certificate asked of the vault when a
|
||||
@@ -590,45 +640,27 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
return certifyThisNode(ctx, opts.state, node, trust, session.AskVault, func() { sayFacts(ctx, ag) })
|
||||
},
|
||||
}
|
||||
// And its answer to every placement candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into
|
||||
// And its answer to every assignment candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into
|
||||
// its own key, `<node>.node`, under candidates.
|
||||
shadowHooks := hooks
|
||||
shadowHooks.refused, shadowHooks.certify = nil, nil
|
||||
shadowHooks.refused, shadowHooks.kept, shadowHooks.certify, shadowHooks.seatList = nil, nil, nil, nil
|
||||
shadowHooks.seatListFailed = false
|
||||
shadowEnv := composerEnv(session, node, say, shadowHooks)
|
||||
// And, after each answer, what runs of an own secret the answer says the vault holds another value of, handed to
|
||||
// the vault while the node applies the controller's compositions (issue 1126, cause C4; ownresync).
|
||||
composes := &composesNow{}
|
||||
resync := ownResync(session, node, say, shadowEnv, composes)
|
||||
shadow := selfcompose.NewShadow(shadowEnv,
|
||||
func(ctx context.Context, answers map[string]delivery.CandidateAnswer) {
|
||||
session.NoteCandidates(ctx, delivery.WithValuesDiffer(answers))
|
||||
go resync.take(ctx, answers)
|
||||
func(ctx context.Context, answers map[string]nodeengine.CandidateAnswer) {
|
||||
session.NoteCandidates(ctx, compositions.WithValuesDiffer(answers))
|
||||
})
|
||||
go shadow.Keep(run, link.Backoff) // this run's alone: a switch begins a run, and its shadow, again
|
||||
composer, why := selfComposer(linked, session, node, say, hooks)
|
||||
if linked.Err() != nil {
|
||||
// The link ended before the node knew whose compositions to apply: it chose neither, and waits for the next.
|
||||
stopRun()
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
if composer != nil {
|
||||
composes.set(delivery.ComposesOwn)
|
||||
env.Composed, env.SelfComposed, env.Trusted, env.Policy = composer, true, nil, composer.Policy
|
||||
session.NoteComposes(linked, delivery.ComposesOwn, why)
|
||||
// And once the catalogue no longer places it, the agent begins again on the controller's compositions: the
|
||||
// placement key deleted ends this run, the way back (issue 1126, the seed's revert).
|
||||
go endWhenUnplaced(run, session, node, stopRun, say)
|
||||
} else {
|
||||
composes.set(delivery.ComposesController)
|
||||
go resync.again(run)
|
||||
session.NoteComposes(linked, delivery.ComposesController, why)
|
||||
// And once the catalogue places it, the agent begins again composing its own: the placement key appearing ends
|
||||
// this run, so a node does not wait for a restart to follow the seed of its placement (ADR 0549 §4).
|
||||
go endWhenPlaced(run, session, node, stopRun, say, func(reason string) {
|
||||
session.NoteComposes(linked, delivery.ComposesController, why+"; "+reason)
|
||||
})
|
||||
}
|
||||
shadowNow.Store(shadow) // what the verb compose-candidate composes a check's candidate with
|
||||
// Its own composer, always (issue 1126, A7): a composition made elsewhere (the controller's, in the node's
|
||||
// composition bucket) is never applied. While the seat's assignment holds no key for this node it composes
|
||||
// nothing and keeps running what it runs; a assignment it cannot watch stops the agent, said, and it begins again.
|
||||
composer := selfComposer(session, node, hooks, say)
|
||||
machinesNow.Store(composer) // the machines a failing network part points at, by the assignments (issue 774)
|
||||
composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone
|
||||
env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday
|
||||
env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does
|
||||
session.NoteComposes(linked, nodeengineclient.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere")
|
||||
ag = agent.New(env)
|
||||
own.set(ag)
|
||||
current.set(ag)
|
||||
@@ -636,6 +668,8 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
stopRun()
|
||||
cancel()
|
||||
current.set(nil)
|
||||
composerNow.Store(nil)
|
||||
shadowNow.Store(nil)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -665,14 +699,60 @@ var reloadSettles = 10 * time.Second
|
||||
type agentNow struct {
|
||||
mu sync.Mutex
|
||||
ag *agent.Agent
|
||||
// last is the view of what runs the previous agent held when it stopped, kept in memory: while the next agent has
|
||||
// read no haves yet, what runs is this and never the retired kept declaration (a bus link that drops restarts the
|
||||
// agent, and every fill in that window was refused; issue 924).
|
||||
last *runsView
|
||||
// view reads an agent's view; viewOf unless a test stands in for an agent that holds the node.
|
||||
view func(*agent.Agent) *runsView
|
||||
}
|
||||
|
||||
func viewOf(a *agent.Agent) *runsView { return &runsView{a.Everything(), a.HoldsNode()} }
|
||||
|
||||
// runsView is what an agent said runs, to be read after the agent stopped.
|
||||
type runsView struct {
|
||||
everything []compositions.Composed
|
||||
holdsNode bool
|
||||
}
|
||||
|
||||
func (c *agentNow) set(a *agent.Agent) {
|
||||
c.mu.Lock()
|
||||
if a == nil && c.ag != nil {
|
||||
read := c.view
|
||||
if read == nil {
|
||||
read = viewOf
|
||||
}
|
||||
if v := read(c.ag); len(v.everything) > 0 || v.holdsNode {
|
||||
c.last = v
|
||||
}
|
||||
}
|
||||
c.ag = a
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *agentNow) viewFn() func(*agent.Agent) *runsView {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.view == nil {
|
||||
return viewOf
|
||||
}
|
||||
return c.view
|
||||
}
|
||||
|
||||
// holdsNode drops the kept view once the agent in hand holds the node: its own view is complete from then on.
|
||||
func (c *agentNow) holdsNode() {
|
||||
c.mu.Lock()
|
||||
c.last = nil
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// lastView is the view the last agent held when it stopped, nil when none held any.
|
||||
func (c *agentNow) lastView() *runsView {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.last
|
||||
}
|
||||
|
||||
func (c *agentNow) get() *agent.Agent {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -680,7 +760,13 @@ func (c *agentNow) get() *agent.Agent {
|
||||
}
|
||||
|
||||
// applyComposition applies one composition: a module's, or the node's own.
|
||||
func applyComposition(ctx context.Context, opts options, c delivery.Composed, others []delivery.Composed,
|
||||
//
|
||||
// placed is the modules a want still places here besides this one: none of their resources is removed by this apply,
|
||||
// which is refused instead (issue 774).
|
||||
//
|
||||
// removed is the modules whose want says removed: in the node's own apply, a resource of any other module goes only when
|
||||
// its module is among them (issue 774).
|
||||
func applyComposition(ctx context.Context, opts options, c compositions.Composed, others []compositions.Composed, placed, removed map[string]bool,
|
||||
sched *apply.Scheduler, say link.Announce) agent.Result {
|
||||
name := scopeName(c)
|
||||
if c.Module == "" {
|
||||
@@ -696,17 +782,30 @@ func applyComposition(ctx context.Context, opts options, c delivery.Composed, ot
|
||||
if err := refuseEmptyWrites(d); err != nil {
|
||||
return failedWith(err.Error())
|
||||
}
|
||||
scope := apply.Scope{Module: name, Others: resourcesOf(others)}
|
||||
scope := apply.Scope{Module: name, Others: resourcesOf(others), Placed: placed}
|
||||
if name == agent.NodeModule {
|
||||
scope.RemovedByWant = removed
|
||||
if scope.RemovedByWant == nil {
|
||||
scope.RemovedByWant = map[string]bool{}
|
||||
}
|
||||
}
|
||||
got := applyModuleHeld(ctx, opts, d, &scope, sched, say)
|
||||
if got.Refused != "" {
|
||||
return failedWith(got.Refused)
|
||||
}
|
||||
// An image that could not be pulled from a store is said with its holder, for the engine to try the apply again once the
|
||||
// holder has settled (design 51, ENG-8).
|
||||
failedOn := func(why string) agent.Result {
|
||||
res := failedWith(why)
|
||||
res.Pulls = pullsOf(d, got.Err)
|
||||
return res
|
||||
}
|
||||
results := apply.ModuleResults(d, got.Outcome, got.Err)
|
||||
if r, ok := results[name]; ok && r.Failed {
|
||||
return failedWith(r.Detail)
|
||||
return failedOn(r.Detail)
|
||||
}
|
||||
if got.Err != nil {
|
||||
return failedWith(got.Err.Error())
|
||||
return failedOn(got.Err.Error())
|
||||
}
|
||||
if got.Probe != nil {
|
||||
return failedWith(got.Probe.partial().Error())
|
||||
@@ -715,9 +814,9 @@ func applyComposition(ctx context.Context, opts options, c delivery.Composed, ot
|
||||
}
|
||||
|
||||
// removeComposition removes one module's own resources and nothing of another's.
|
||||
func removeComposition(ctx context.Context, opts options, module string, others []delivery.Composed,
|
||||
func removeComposition(ctx context.Context, opts options, module string, others []compositions.Composed, placed map[string]bool,
|
||||
sched *apply.Scheduler, say link.Announce) agent.Result {
|
||||
scope := apply.Scope{Module: module, Others: resourcesOf(others)}
|
||||
scope := apply.Scope{Module: module, Others: resourcesOf(others), Placed: placed}
|
||||
got := applyModuleHeld(ctx, opts, &declaration.Declaration{Version: declaration.Version}, &scope, sched, say)
|
||||
switch {
|
||||
case got.Refused != "":
|
||||
@@ -742,7 +841,19 @@ func busyModules() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// keptRetired says the kept declaration is read no more: every module the seat's placement puts on this node has a have of
|
||||
// machinesNow is the composer whose assignments name the mesh's machines by address, for the health report's targets
|
||||
// (writeHealthReport); nil on a node that does not compose its own, whose parts then name no target.
|
||||
var machinesNow atomic.Pointer[selfcompose.Composer]
|
||||
|
||||
// composerNow is the composer of the run that composes the node's own now, nil while the node applies the controller's
|
||||
// compositions or no run is going: what the verb compose asks a pass of (issue 774).
|
||||
var composerNow atomic.Pointer[selfcompose.Composer]
|
||||
|
||||
// shadowNow is the shadow of the run now, which answers assignment candidates and the verb compose-candidate; nil
|
||||
// between runs.
|
||||
var shadowNow atomic.Pointer[selfcompose.Shadow]
|
||||
|
||||
// keptRetired says the kept declaration is read no more: every module the seat's assignment puts on this node has a have of
|
||||
// its own (placedAllHave), so what runs is the agent's alone (ADR 0550 §2, design 47's L3 s9).
|
||||
var keptRetired atomic.Bool
|
||||
|
||||
@@ -776,7 +887,7 @@ func keptDeclaration(statePath string, signer ed25519.PublicKey) (*declaration.D
|
||||
// (adoption, identities, groups, libraries) from the node's composition once the agent holds it and from the kept
|
||||
// declaration until then. What the node judges its health by, and what the filling, the take act and the judgement of
|
||||
// directories read.
|
||||
func whatRuns(everything []delivery.Composed, holdsNode bool, kept func() (*declaration.Declaration, error)) (*declaration.Declaration, error) {
|
||||
func whatRuns(everything []compositions.Composed, holdsNode bool, kept func() (*declaration.Declaration, error)) (*declaration.Declaration, error) {
|
||||
d, err := declarationOf(everything...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -825,11 +936,23 @@ func declaredNow(statePath string, signer ed25519.PublicKey) (*declaration.Decla
|
||||
func runsFromAgent(current *agentNow) func(string, ed25519.PublicKey) (*declaration.Declaration, error) {
|
||||
return func(statePath string, signer ed25519.PublicKey) (*declaration.Declaration, error) {
|
||||
kept := func() (*declaration.Declaration, error) { return keptDeclaration(statePath, signer) }
|
||||
ag := current.get()
|
||||
if ag == nil {
|
||||
var view *runsView
|
||||
if ag := current.get(); ag != nil {
|
||||
view = current.viewFn()(ag)
|
||||
if view.holdsNode {
|
||||
current.holdsNode()
|
||||
}
|
||||
}
|
||||
// An agent that has read no haves yet, or none: the last view held, never the retired kept declaration.
|
||||
if view == nil || (len(view.everything) == 0 && !view.holdsNode) {
|
||||
if last := current.lastView(); last != nil {
|
||||
view = last
|
||||
}
|
||||
}
|
||||
if view == nil {
|
||||
return kept()
|
||||
}
|
||||
return whatRuns(ag.Everything(), ag.HoldsNode(), kept)
|
||||
return whatRuns(view.everything, view.holdsNode, kept)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,8 +985,8 @@ func refuseEmptyWrites(d *declaration.Declaration) error {
|
||||
// recordedIn says the engine's applied state holds a record of every resource of a composition: what the agent believes
|
||||
// of a have or the kept declaration only when it does, so a resource removed since is made again (issue 1945). A state
|
||||
// that cannot be read says yes: the agent then believes what it was told, as before.
|
||||
func recordedIn(statePath string, say func(string)) func(ctx context.Context, c delivery.Composed) bool {
|
||||
return func(_ context.Context, c delivery.Composed) bool {
|
||||
func recordedIn(statePath string, say func(string)) func(ctx context.Context, c compositions.Composed) bool {
|
||||
return func(_ context.Context, c compositions.Composed) bool {
|
||||
known, err := store.Load(statePath)
|
||||
if err != nil {
|
||||
if say != nil {
|
||||
@@ -923,55 +1046,44 @@ func applyContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), applyBound)
|
||||
}
|
||||
|
||||
// selfComposer is the node's own composer when the catalogue places the node (its key `placement.<node>` in the
|
||||
// indexer's state), and nil, said once, while it does not or the key cannot be read.
|
||||
// composerHooks are what the composer reads of the agent and the machine (selfcompose.Env Running, Unseal, Refused).
|
||||
type composerHooks struct {
|
||||
running func() []delivery.Composed
|
||||
running func() []compositions.Composed
|
||||
unseal func(string) ([]byte, error)
|
||||
refused func(why string, held map[string]string)
|
||||
held func(module string) (delivery.Seals, []string, error)
|
||||
kept func(kept map[string][]string)
|
||||
held func(module string) (compositions.Seals, []string, error)
|
||||
// vaultAuthority and certify are selfcompose.Env's VaultAuthority and Certify.
|
||||
vaultAuthority func() string
|
||||
certify func(ctx context.Context) error
|
||||
// seatList is this run's reader of the seat list: the composer hands nothing new until it was read (ADR 0636 §1).
|
||||
seatList *seatListState
|
||||
// seatListFailed says the reader could not start: the composer then holds like a reader that waits (selfcompose.Unread).
|
||||
seatListFailed bool
|
||||
}
|
||||
|
||||
func selfComposer(ctx context.Context, session *link.Session, node string, say func(string), hooks composerHooks) (*selfcompose.Composer, string) {
|
||||
key, err := delivery.PlacementKey(node)
|
||||
if err != nil {
|
||||
say("this node applies the controller's compositions: " + err.Error())
|
||||
return nil, err.Error()
|
||||
}
|
||||
// selfComposer is the node's own composer: it composes what the seat's assignment places on the node, and nothing while
|
||||
// the assignment holds no key for it (issue 1126).
|
||||
func selfComposer(session *link.Session, node string, hooks composerHooks, say func(string)) *selfcompose.Composer {
|
||||
env := composerEnv(session, node, say, hooks)
|
||||
unreadable := ""
|
||||
placed, err := selfcompose.Placed(ctx, env.Get, node, link.Backoff, func(m string) {
|
||||
if strings.Contains(m, "cannot be read") {
|
||||
unreadable = m
|
||||
}
|
||||
say(m)
|
||||
}, selfcompose.ComposedItsOwn(ctx, env.Get, node))
|
||||
if err != nil {
|
||||
return nil, "its placement could not be read before the link ended: " + err.Error()
|
||||
}
|
||||
if !placed {
|
||||
why := "the seat's placement state holds no key for it"
|
||||
if unreadable != "" {
|
||||
why = "its placement cannot be read, and it never composed its own: " + unreadable
|
||||
}
|
||||
say("this node applies the controller's compositions: " + why)
|
||||
return nil, why
|
||||
}
|
||||
why := "the seat's placement state holds its key (" + key + ")"
|
||||
say("this node composes its own modules: " + why)
|
||||
return selfcompose.New(env), why
|
||||
// And it keeps each placed module's data items in its own state data, which the node's disk-load watches (novox/hq
|
||||
// ADR 0577 clause 1); the shadow composes for candidates and keeps none.
|
||||
env.Data = link.EngineData{Session: session}
|
||||
// And the packages its compositions declare in its own key of the state packages, which pacman reads (novox/hq ADR
|
||||
// 0565 §6).
|
||||
env.Packages = link.EnginePackages{Session: session}
|
||||
// And every composition it makes in its composition state composed-<node>, a record the provisioner, the assigner
|
||||
// and the view read and nothing applies (novox/hq ADR 0593 §6).
|
||||
env.ComposedState = link.EngineComposed{Session: session, Node: node}
|
||||
return selfcompose.New(env)
|
||||
}
|
||||
|
||||
// composerEnv is what the node's composers read of the bus, the vault and the agent: its own composer and its answer
|
||||
// to placement candidates (selfcompose.Shadow) alike.
|
||||
// to assignment candidates (selfcompose.Shadow) alike.
|
||||
func composerEnv(session *link.Session, node string, say func(string), hooks composerHooks) selfcompose.Env {
|
||||
return selfcompose.Env{Node: node,
|
||||
env := selfcompose.Env{Node: node,
|
||||
Watch: func(ctx context.Context, bucket, pattern string) (<-chan agent.Update, error) {
|
||||
if bucket == delivery.PlacementBucket || bucket == delivery.PlacementCandidatesBucket {
|
||||
if bucket == buckets.Assignments || bucket == buckets.ProposalCandidates {
|
||||
// The seat's states are made when their holder starts: a node-engine that starts first waits for the
|
||||
// bus's word that the bucket was created, and the shadow and the composer say they wait.
|
||||
return bucketWatch{session, bucket}.WatchAwait(ctx, pattern, func(err error) {
|
||||
@@ -997,74 +1109,12 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com
|
||||
Hear: session.Hear,
|
||||
// And a moved module's templates archive, in the artifact store, as every archive is fetched (issue 1126).
|
||||
Fetch: apply.Fetch,
|
||||
Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held,
|
||||
Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held, Kept: hooks.kept,
|
||||
VaultAuthority: hooks.vaultAuthority, Certify: hooks.certify}
|
||||
}
|
||||
|
||||
// endWhenUnplaced calls stop once this node's placement key is deleted from the seat's placement (after the watch
|
||||
// caught up, or gone already), so the agent begins again and applies the controller's compositions, as a node the
|
||||
// catalogue does not place does from its start: the way back (issue 1126, the seed's revert). A watch that cannot be
|
||||
// made is said; the node then returns at its next start.
|
||||
func endWhenUnplaced(ctx context.Context, session *link.Session, node string, stop func(), say link.Announce) {
|
||||
key, err := delivery.PlacementKey(node)
|
||||
if err != nil {
|
||||
return
|
||||
if hooks.seatList != nil {
|
||||
env.SeatList, env.SeatListChanged = hooks.seatList.reader, hooks.seatList.changed
|
||||
} else if hooks.seatListFailed {
|
||||
env.SeatList = selfcompose.Unread{}
|
||||
}
|
||||
updates, err := bucketWatch{session, delivery.PlacementBucket}.Watch(ctx, key)
|
||||
if err != nil {
|
||||
say("whether this node's key in the seat's placement state still exists is not followed (" + err.Error() + "): it returns to the controller's compositions at its next start")
|
||||
return
|
||||
}
|
||||
placed := false
|
||||
for u := range updates {
|
||||
switch {
|
||||
case u.Caught && placed:
|
||||
continue
|
||||
case u.Caught || u.Deleted || len(u.Value) == 0:
|
||||
say("this node's key in the seat's placement state is gone (" + key + "): the node agent begins again, applying the controller's compositions")
|
||||
stop()
|
||||
return
|
||||
default:
|
||||
placed = true
|
||||
}
|
||||
}
|
||||
if ctx.Err() == nil {
|
||||
say("the watch of " + key + " ended while the link stays: whether this node's key in the seat's placement state still exists is followed again at the node agent's next start")
|
||||
}
|
||||
}
|
||||
|
||||
// endWhenPlaced calls stop once this node's placement key appears in the seat's placement (a value after the watch
|
||||
// caught up, or one already there), so the agent begins again and composes its own. A watch that cannot be made is said;
|
||||
// the node then switches at its next start.
|
||||
func endWhenPlaced(ctx context.Context, session *link.Session, node string, stop func(), say link.Announce, note func(string)) {
|
||||
key, err := delivery.PlacementKey(node)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// The seat's placement state is made when its holder starts; the delivery brings the grants part and the holder first,
|
||||
// so it is normally there. A node-engine that starts before it waits for the bus's word that the bucket was created
|
||||
// (no timer), says so once in the journal and in the node's own have, where the watchman reads it, and stays on the
|
||||
// controller's compositions until its key is written. A watch the account may not make is said the same way, and the
|
||||
// node stays until its next start.
|
||||
updates, err := bucketWatch{session, delivery.PlacementBucket}.WatchAwait(ctx, key, func(err error) {
|
||||
why := "this node's key in the seat's placement state is waited for, the state is not on the bus yet (" + err.Error() + "): it stays on the controller's compositions and is watched when the bus says the state was created"
|
||||
say(why)
|
||||
note(why)
|
||||
})
|
||||
if err != nil {
|
||||
why := "this node's key in the seat's placement state cannot be followed (" + err.Error() + "): it stays on the controller's compositions until its next start"
|
||||
say(why)
|
||||
note(why)
|
||||
return
|
||||
}
|
||||
for u := range updates {
|
||||
if !u.Caught && !u.Deleted && len(u.Value) > 0 {
|
||||
say("this node's key in the seat's placement state is written now (" + key + "): the node agent begins again, composing its own modules")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
if ctx.Err() == nil {
|
||||
say("the watch of " + key + " ended while the link stays: whether this node's key in the seat's placement state is written is followed again at the node agent's next start")
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,11 @@ func TestNoPackageHearsOrOrdersAWholeDeclaration(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -26,12 +26,10 @@ const (
|
||||
)
|
||||
|
||||
// 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; kick is the follower of
|
||||
// the user list on the link open now, told when the key is first heard or changes.
|
||||
// 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
|
||||
kick func()
|
||||
mu sync.Mutex
|
||||
pem string
|
||||
}
|
||||
|
||||
// vaultKey is the vault's key this engine heard.
|
||||
@@ -43,8 +41,7 @@ func (h *heardKey) get() string {
|
||||
return h.pem
|
||||
}
|
||||
|
||||
// heard keeps the key a node-key answer gave, and tells the follower when it is a first or changed one. An answer
|
||||
// with no key (a vault before this one) changes nothing.
|
||||
// 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"`
|
||||
@@ -53,24 +50,8 @@ func (h *heardKey) heard(answer json.RawMessage) {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
changed := a.VaultKey != h.pem
|
||||
h.pem = a.VaultKey
|
||||
kick := h.kick
|
||||
h.mu.Unlock()
|
||||
if changed && kick != nil {
|
||||
kick()
|
||||
}
|
||||
}
|
||||
|
||||
// following names the follower told when the key changes, and tells it at once when a key is known.
|
||||
func (h *heardKey) following(kick func()) {
|
||||
h.mu.Lock()
|
||||
h.kick = kick
|
||||
known := h.pem != ""
|
||||
h.mu.Unlock()
|
||||
if known && kick != nil {
|
||||
kick()
|
||||
}
|
||||
}
|
||||
|
||||
// nodeKeyReask is how often the key is stated again though it did not change, so a changed vault key is heard: the
|
||||
|
||||
@@ -162,29 +162,22 @@ func asksAgainUntilItHearsTheVaultsKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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, told to the follower once when first heard and again only when changed; an answer without one
|
||||
// (an older vault) or with no public key changes nothing (ADR 0547 clause 9).
|
||||
// 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{}
|
||||
kicks := 0
|
||||
h.following(func() { kicks++ })
|
||||
h.heard(json.RawMessage(`{"recorded":true}`))
|
||||
h.heard(json.RawMessage(`{"recorded":true,"vault_key":"not a key"}`))
|
||||
if h.get() != "" || kicks != 0 {
|
||||
t.Fatalf("an answer with no key was kept: %q, %d kicks", h.get(), kicks)
|
||||
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)
|
||||
h.heard(answer)
|
||||
if h.get() != pem || kicks != 1 {
|
||||
t.Fatalf("key %q, %d kicks, want the key and one", h.get(), kicks)
|
||||
}
|
||||
h.following(func() { kicks += 10 })
|
||||
if kicks != 11 {
|
||||
t.Fatalf("a follower named after the key was heard was not told: %d", kicks)
|
||||
if h.get() != pem {
|
||||
t.Fatalf("key %q, want the key", h.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/ownresync"
|
||||
"github.com/novox/mesh-host/internal/selfcompose"
|
||||
)
|
||||
|
||||
// composesNow is whose compositions this run of the agent applies, once decided ("" before).
|
||||
type composesNow struct {
|
||||
mu sync.Mutex
|
||||
now string
|
||||
}
|
||||
|
||||
func (c *composesNow) set(s string) { c.mu.Lock(); c.now = s; c.mu.Unlock() }
|
||||
func (c *composesNow) get() string { c.mu.Lock(); defer c.mu.Unlock(); return c.now }
|
||||
|
||||
// resyncStep is the step that hands the vault what runs of an own secret a shadow answer says another value of (issue
|
||||
// 1126, cause C4; internal/ownresync): one pass at a time, and the last answers taken again once the agent has decided
|
||||
// whose compositions it applies.
|
||||
type resyncStep struct {
|
||||
r *ownresync.Resync
|
||||
one sync.Mutex
|
||||
mu sync.Mutex
|
||||
last map[string]delivery.CandidateAnswer
|
||||
// tries and pending are the step taken again after a bound when a read or an ask the bus did not answer stopped it
|
||||
// (Outcome.Retry, issue 1126, 2026-10-28), one wait at a time.
|
||||
tries int
|
||||
pending bool
|
||||
}
|
||||
|
||||
func ownResync(session *link.Session, node string, say func(string), env selfcompose.Env, composes *composesNow) *resyncStep {
|
||||
return &resyncStep{r: ownresync.New(ownresync.Env{Node: node, Composes: composes.get, Running: env.Running,
|
||||
Get: env.Get, Unseal: env.Unseal, Seal: identity.Seal, Say: say,
|
||||
Ask: func(ctx context.Context, verb string, args map[string]any) (json.RawMessage, error) {
|
||||
return session.AskVault(ctx, verb, args)
|
||||
}})}
|
||||
}
|
||||
|
||||
// take keeps answers as the last and takes them.
|
||||
func (s *resyncStep) take(ctx context.Context, answers map[string]delivery.CandidateAnswer) {
|
||||
s.mu.Lock()
|
||||
s.last = answers
|
||||
s.mu.Unlock()
|
||||
s.again(ctx)
|
||||
}
|
||||
|
||||
// again takes the last answers, and once more after a bound (selfcompose.LaterWait) while the bus did not answer for one.
|
||||
func (s *resyncStep) again(ctx context.Context) {
|
||||
s.one.Lock()
|
||||
defer s.one.Unlock()
|
||||
s.mu.Lock()
|
||||
last := s.last
|
||||
s.mu.Unlock()
|
||||
if last == nil {
|
||||
return
|
||||
}
|
||||
retry := false
|
||||
for _, o := range s.r.Take(ctx, last) {
|
||||
retry = retry || o.Retry
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !retry {
|
||||
s.tries = 0
|
||||
return
|
||||
}
|
||||
if s.pending || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
wait := selfcompose.LaterWait(s.tries)
|
||||
s.tries++
|
||||
s.pending = true
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(wait):
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.pending = false
|
||||
s.mu.Unlock()
|
||||
if ctx.Err() == nil {
|
||||
s.again(ctx)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,11 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
@@ -21,7 +20,7 @@ func TestACompositionIsRecordedOnlyWhenEveryResourceIs(t *testing.T) {
|
||||
if err := store.Save(path, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := delivery.Composed{Node: "ace", Module: "ca-trust", Resources: []json.RawMessage{
|
||||
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) {
|
||||
@@ -87,7 +86,7 @@ func TestAFilledFileDoesNotMakeItsCompositionUnrecorded(t *testing.T) {
|
||||
if err := store.Save(path, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := delivery.Composed{Node: "ace", Module: "ca-trust", Filled: []string{"/etc/ca-filled"}, Resources: []json.RawMessage{
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+88
-116
@@ -5,45 +5,43 @@ import (
|
||||
"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-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"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). Two signers: the
|
||||
// controller's key, which the node was enrolled with (its membership's signer), and the vault's key, read from the vault's
|
||||
// state `signers`, which only the vault may write: the bus grants are its anchor, as the wants' and the placement's (the
|
||||
// lead's design limit, 2026-10-28). The controller's key is trusted until its lease is handed away, heard on the lease key
|
||||
// (buscompose.LeaseHandedAway) and never on a clock; from then the vault's alone. What it trusts now the node states in its
|
||||
// record (NodeRecord.Trusts), so the gate before the lease write reads every node's from the bus.
|
||||
// **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
|
||||
controller ed25519.PublicKey
|
||||
handedAway bool
|
||||
vault ed25519.PublicKey
|
||||
authority string
|
||||
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 the controller's key alone until the vault's is read.
|
||||
func newMeshTrust(controller ed25519.PublicKey) *meshTrust {
|
||||
t := &meshTrust{}
|
||||
if len(controller) == ed25519.PublicKeySize {
|
||||
t.controller = controller
|
||||
}
|
||||
return t
|
||||
}
|
||||
// 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()) {
|
||||
@@ -52,51 +50,24 @@ func (t *meshTrust) onChange(f func()) {
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// Controller is the controller's key while the controller holds its lease, and nil once the lease is handed away.
|
||||
func (t *meshTrust) Controller() ed25519.PublicKey {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
// 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()
|
||||
if t.handedAway {
|
||||
return nil
|
||||
}
|
||||
return t.controller
|
||||
}
|
||||
|
||||
// HandedAway says the controller's lease was handed away, as heard on the lease key.
|
||||
func (t *meshTrust) HandedAway() bool {
|
||||
if t == nil {
|
||||
return false
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.handedAway
|
||||
}
|
||||
|
||||
// Signers are the keys a mesh signature is verified against now: the controller's while it holds the lease, and the
|
||||
// vault's once read.
|
||||
func (t *meshTrust) Signers() []delivery.Signer {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
var out []delivery.Signer
|
||||
if t.controller != nil && !t.handedAway {
|
||||
out = append(out, delivery.Signer{Name: delivery.SignerController, Key: t.controller})
|
||||
}
|
||||
var out []meshvaultclient.Signer
|
||||
if t.vault != nil {
|
||||
out = append(out, delivery.Signer{Name: delivery.SignerVault, Key: t.vault})
|
||||
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) (delivery.Signer, error) {
|
||||
context, err := delivery.SignContext(kind)
|
||||
func (t *meshTrust) Verify(kind string, msg, sig []byte) (meshvaultclient.Signer, error) {
|
||||
context, err := meshvaultclient.SignContext(kind)
|
||||
if err != nil {
|
||||
return delivery.Signer{}, err
|
||||
return meshvaultclient.Signer{}, err
|
||||
}
|
||||
return delivery.VerifyBy(t.Signers(), context, msg, sig)
|
||||
return meshvaultclient.VerifyBy(t.Signers(), context, msg, sig)
|
||||
}
|
||||
|
||||
// VaultAuthority is the vault's authority certificate as read from `signers`, or "".
|
||||
@@ -116,9 +87,9 @@ func (t *meshTrust) Trusts() []string {
|
||||
}
|
||||
authorities := map[string]string{}
|
||||
if a := t.VaultAuthority(); a != "" {
|
||||
authorities[delivery.SignerVault] = a
|
||||
authorities[meshvaultclient.SignerVault] = a
|
||||
}
|
||||
return delivery.Trusts(t.Signers(), authorities)
|
||||
return meshvaultclient.Trusts(t.Signers(), authorities)
|
||||
}
|
||||
|
||||
// change applies f under the lock and tells who follows when Trusts differs after it.
|
||||
@@ -137,25 +108,25 @@ func (t *meshTrust) change(f func()) {
|
||||
// check; a deleted key is no longer trusted.
|
||||
func (t *meshTrust) heardSigner(key string, value []byte, deleted bool, say link.Announce) {
|
||||
switch key {
|
||||
case delivery.SignersKeyKey:
|
||||
case meshvault.SignersByKeyKey():
|
||||
var pub ed25519.PublicKey
|
||||
if !deleted && len(value) > 0 {
|
||||
k, err := delivery.DecodeSignerKey(value)
|
||||
k, err := meshvaultclient.DecodeSignerKey(value)
|
||||
if err == nil {
|
||||
pub, err = k.PublicKey()
|
||||
pub, err = meshvaultclient.SignerPublicKey(k)
|
||||
}
|
||||
if err != nil {
|
||||
say("the vault's signing key in " + delivery.SignersRead + " is not trusted: " + err.Error())
|
||||
say("the vault's signing key in " + buckets.SignersRead + " is not trusted: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
t.change(func() { t.vault = pub })
|
||||
case delivery.SignersAuthorityKey:
|
||||
case meshvault.SignersByAuthorityKey():
|
||||
pem := ""
|
||||
if !deleted && len(value) > 0 {
|
||||
a, err := delivery.DecodeSignerAuthority(value)
|
||||
a, err := meshvaultclient.DecodeSignerAuthority(value)
|
||||
if err != nil {
|
||||
say("the vault's authority in " + delivery.SignersRead + " is not trusted: " + err.Error())
|
||||
say("the vault's authority in " + buckets.SignersRead + " is not trusted: " + err.Error())
|
||||
return
|
||||
}
|
||||
pem = a.Certificate
|
||||
@@ -164,21 +135,11 @@ func (t *meshTrust) heardSigner(key string, value []byte, deleted bool, say link
|
||||
}
|
||||
}
|
||||
|
||||
// heardLease takes the controller's lease key as read: once handed away to a holder that is no controller, the
|
||||
// controller's key is trusted no more. A lease that cannot be read changes nothing.
|
||||
func (t *meshTrust) heardLease(raw []byte, found bool, readErr error) {
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
handed, _ := buscompose.LeaseHandedAway(raw, found, nil)
|
||||
t.change(func() { t.handedAway = handed })
|
||||
}
|
||||
|
||||
// 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` and the controller's lease on every link, as watches (never a poll), for as
|
||||
// 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 := ""
|
||||
@@ -196,34 +157,17 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s
|
||||
return
|
||||
}
|
||||
watching, stop := context.WithCancel(linked)
|
||||
signers, serr := session.KVWatch(watching, delivery.SignersBucket, ">")
|
||||
lease, lerr := session.KVWatch(watching, buscompose.LeaseBucket, buscompose.ControllerLeaseKey)
|
||||
if serr != nil || lerr != nil {
|
||||
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; "+
|
||||
"the controller's lease: %v): it trusts what it last read, and looks again in %s", serr, lerr, trustFollowAgain))
|
||||
signers, lease = nil, nil
|
||||
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 signers != nil || lease != nil {
|
||||
select {
|
||||
case u, ok := <-signers:
|
||||
if !ok {
|
||||
signers = nil
|
||||
continue
|
||||
}
|
||||
for u := range signers {
|
||||
if !u.Caught {
|
||||
trust.heardSigner(u.Key, u.Value, u.Deleted, say)
|
||||
}
|
||||
case u, ok := <-lease:
|
||||
if !ok {
|
||||
lease = nil
|
||||
continue
|
||||
}
|
||||
if !u.Caught {
|
||||
trust.heardLease(u.Value, !u.Deleted && len(u.Value) > 0, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
stop()
|
||||
@@ -235,41 +179,69 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s
|
||||
}
|
||||
}
|
||||
|
||||
// placedAllHave says whether every module the seat mesh-placement 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 placement or haves
|
||||
// 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 := delivery.PlacementKey(node)
|
||||
key, err := meshmoduleassignerclient.NodeKey(node)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
h, err := session.KVHistory(ctx, delivery.PlacementBucket, key)
|
||||
if err != nil || len(h) == 0 || h[len(h)-1].Deleted {
|
||||
return false, "its placement is not read"
|
||||
// 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 := delivery.DecodePlacement(h[len(h)-1].Value)
|
||||
p, err := placedModules(node, values)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return allHave(p, func(module string) (bool, error) {
|
||||
_, ok, err := session.ReadHaveErr(ctx, module)
|
||||
return ok, err
|
||||
return allHave(p, func(module string) (nodeengine.HaveByNodeModuleValue, bool, error) {
|
||||
return session.ReadHaveErr(ctx, module)
|
||||
})
|
||||
}
|
||||
|
||||
// allHave says whether has finds a have for every module p places, and why not.
|
||||
func allHave(p delivery.Placement, has func(module string) (bool, error)) (bool, string) {
|
||||
// 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-placement places no module here"
|
||||
return false, "the seat mesh-module-assigner places no module here"
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
ok, err := has(m.Module)
|
||||
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))
|
||||
}
|
||||
@@ -287,11 +259,11 @@ func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust,
|
||||
if err != nil || len(key) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("this node's serving key is not an ed25519 key")
|
||||
}
|
||||
raw, err := ask(ctx, delivery.SignVerb, map[string]any{"kind": delivery.KindNodeCertificate, "node": node})
|
||||
raw, err := ask(ctx, meshvault.VerbSign, map[string]any{"kind": meshvaultclient.KindNodeCertificate, "node": node})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var a delivery.SignAnswer
|
||||
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)
|
||||
}
|
||||
@@ -299,7 +271,7 @@ func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust,
|
||||
if trusted == "" {
|
||||
return fmt.Errorf("this node has read no authority of the vault's to check the certificate against")
|
||||
}
|
||||
if err := delivery.CheckNodeCertificate(a.Certificate, []string{trusted}, overlay.InternalName(node),
|
||||
if err := meshvaultclient.CheckNodeCertificate(a.Certificate, []string{trusted}, overlay.InternalName(node),
|
||||
ed25519.PublicKey(key), time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+10
-31
@@ -2,15 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"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/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
@@ -20,22 +20,18 @@ import (
|
||||
"github.com/novox/mesh-host/internal/userlist"
|
||||
)
|
||||
|
||||
// The bridge of novox/hq ADR 0547 §8, wired: the bus node's engine writes the bus's user list alone from the newest
|
||||
// composition of the bus module, under the apply's two locks as a filled file is written (filling.go), and says what
|
||||
// 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, composedBucket string, trusted func() []ed25519.PublicKey,
|
||||
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,
|
||||
Composed: bucketWatch{session, composedBucket},
|
||||
Trusted: trusted,
|
||||
Resolve: func(ctx context.Context, raw json.RawMessage) ([]byte, error) {
|
||||
return resolveUserList(session.WithLink(ctx), raw)
|
||||
},
|
||||
OnDisk: userListOnDisk,
|
||||
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 {
|
||||
@@ -48,7 +44,7 @@ func userListFollower(opts options, session *link.Session, composedBucket string
|
||||
},
|
||||
// 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, delivery.GrantsUserListBucket},
|
||||
Grants: bucketWatch{session, grantsstate.GrantsUserListBucket},
|
||||
Fetch: func(ctx context.Context, name string) ([]byte, error) {
|
||||
return session.GrantsObject(ctx, name, grantsListLimit)
|
||||
},
|
||||
@@ -75,10 +71,7 @@ func userListFollower(opts options, session *link.Session, composedBucket string
|
||||
Kept: func() ([]byte, uint64) { return keptGrantsKey(opts) },
|
||||
Keep: func(value []byte, rev uint64) { keepGrantsKey(opts, value, rev, say) },
|
||||
Backoff: link.Backoff,
|
||||
// The vault's key from its answer to node-key: the loaded list's hashes are handed sealed to it (ADR 0547
|
||||
// clause 9).
|
||||
VaultKey: vaultKey.get,
|
||||
Say: announceOr(say),
|
||||
Say: announceOr(say),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,20 +91,6 @@ func userListFile(raw []byte) (*declaration.File, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// resolveUserList is the list a user-list resource holds: its content, or the object its digest names in this node's
|
||||
// file bucket, fetched and verified (apply.ResolveFileReferences, ADR 0486 §1).
|
||||
func resolveUserList(ctx context.Context, raw []byte) ([]byte, error) {
|
||||
f, err := userListFile(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &declaration.Declaration{Version: declaration.Version, Resources: []declaration.Resource{f}}
|
||||
if err := apply.ResolveFileReferences(ctx, d, link.FilesFrom(ctx)); err != nil {
|
||||
return nil, fmt.Errorf("the user list could not be had by its digest: %w", err)
|
||||
}
|
||||
return []byte(d.Resources[0].(*declaration.File).Content), 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)
|
||||
|
||||
+147
-18
@@ -1,17 +1,27 @@
|
||||
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"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -27,7 +37,7 @@ func aFileOf(module, id string) json.RawMessage {
|
||||
func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
|
||||
kept := &declaration.Declaration{Identities: []declaration.Identity{{Module: "db", Name: "mesh-db"}},
|
||||
Libraries: []declaration.Library{{Library: "media"}}}
|
||||
everything := []delivery.Composed{
|
||||
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
|
||||
}
|
||||
@@ -41,7 +51,7 @@ func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
|
||||
}
|
||||
|
||||
// Once the agent holds the node's composition, its parts are the node's.
|
||||
node := delivery.Composed{Node: "n1", Own: delivery.NodeOwn{Identities: []declaration.Identity{{Module: "web", Name: "mesh-web"}}}}
|
||||
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)
|
||||
@@ -53,6 +63,7 @@ func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
|
||||
|
||||
// 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
|
||||
})
|
||||
@@ -62,6 +73,46 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
|
||||
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")
|
||||
@@ -72,20 +123,35 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
|
||||
if d, err := keptDeclaration(state, nil); err != nil || len(d.Resources) != 1 {
|
||||
t.Fatalf("before the read is retired: %+v, %v", d, err)
|
||||
}
|
||||
placed := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}}
|
||||
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]bool
|
||||
has map[string]nodeengine.HaveByNodeModuleValue
|
||||
want bool
|
||||
}{
|
||||
{"every placed module has a have", map[string]bool{"zsh": true, "lemurs": true}, true},
|
||||
{"one has none yet", map[string]bool{"zsh": true}, false},
|
||||
{"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) (bool, error) { return row.has[m], nil }); got != row.want {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if got, _ := allHave(delivery.Placement{Node: "n1"}, nil); got {
|
||||
// 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)
|
||||
@@ -102,9 +168,9 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
|
||||
// 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 := delivery.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")},
|
||||
own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")},
|
||||
Filled: []string{"/tmp/r6/zsh/rc"}}
|
||||
zsh := delivery.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("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 {
|
||||
@@ -122,7 +188,7 @@ func TestTheNodesOwnCopyOfAModulesResourceIsLeftOut(t *testing.T) {
|
||||
}
|
||||
|
||||
// The same at the node's own apply: what the others declare is theirs.
|
||||
trimmed, dropped := ownWithout(own, []delivery.Composed{zsh})
|
||||
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)
|
||||
}
|
||||
@@ -152,9 +218,9 @@ func TestTheKeptDeclarationsFilledPathsGoToTheirModules(t *testing.T) {
|
||||
strings.Join(filled[agent.NodeModule], ",") != "/etc/motd" || len(filled["zsh"]) != 0 {
|
||||
t.Fatalf("filled by module: %v", filled)
|
||||
}
|
||||
cc := delivery.Composed{Node: "n1", Module: "claude-code", Resources: by["claude-code"], Filled: filled["claude-code"]}
|
||||
node := delivery.Composed{Node: "n1"}
|
||||
d, err := whatRuns([]delivery.Composed{cc, node}, true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
|
||||
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)
|
||||
}
|
||||
@@ -167,10 +233,73 @@ func TestTheKeptDeclarationsFilledPathsGoToTheirModules(t *testing.T) {
|
||||
// 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 := delivery.Composed{Node: "n1", Resources: []json.RawMessage{ownFile}}
|
||||
zsh := delivery.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}}
|
||||
trimmed, dropped := ownWithout(own, []delivery.Composed{zsh})
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,9 +146,8 @@ 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.Session, say func(string)) {
|
||||
host, _ := os.Hostname()
|
||||
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()) {
|
||||
|
||||
@@ -3,7 +3,25 @@ module github.com/novox/mesh-host
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028234900-c2999a4e13cc
|
||||
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
|
||||
@@ -11,12 +29,23 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // 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.0 // 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.1 // 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.15.0 // 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,15 +1,13 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028234900-c2999a4e13cc h1:mOk2tAKTiuvnap/BUIYz91lLVbHi/EB6JPAD6KxTPvc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028234900-c2999a4e13cc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||
github.com/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.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
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.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
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=
|
||||
@@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
|
||||
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.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
|
||||
+838
-165
File diff suppressed because it is too large
Load Diff
+748
-206
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
|
||||
}
|
||||
+212
-11
@@ -312,11 +312,54 @@ type Scope struct {
|
||||
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 {
|
||||
return store.OwnerOf(a, declaration.ModuleGuess) == sc.Module
|
||||
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
|
||||
@@ -441,6 +484,57 @@ func applyScoped(
|
||||
}
|
||||
}
|
||||
|
||||
// **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
|
||||
@@ -456,6 +550,16 @@ func applyScoped(
|
||||
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
|
||||
@@ -506,7 +610,7 @@ func applyScoped(
|
||||
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,
|
||||
@@ -835,6 +939,13 @@ func applyScoped(
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -1468,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:
|
||||
@@ -2379,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).
|
||||
@@ -2505,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)
|
||||
@@ -2655,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.
|
||||
@@ -3061,8 +3175,10 @@ 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)
|
||||
}
|
||||
}
|
||||
// **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
|
||||
@@ -3365,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
|
||||
}
|
||||
@@ -4236,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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -3,6 +3,7 @@ package apply
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
@@ -116,32 +117,107 @@ func TestARecordWithoutAnOwnerBelongsToTheModuleItsIdNames(t *testing.T) {
|
||||
// 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) {
|
||||
dir := t.TempDir()
|
||||
fact := filepath.Join(dir, "suffix")
|
||||
gone := filepath.Join(dir, "gone.conf")
|
||||
own := declare(t, `{"id":"systemd-resolved.fact-suffix","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)
|
||||
}
|
||||
// systemd-resolved now has a composition of its own, not applied yet; old.gone is no module's any more.
|
||||
moved := declare(t, `{"id":"systemd-resolved.fact-suffix","type":"file","path":"`+fact+`","content":"mesh\n","module":"systemd-resolved"}`)
|
||||
report, state, err := ApplyModule(context.Background(), archHost(t), &declaration.Declaration{},
|
||||
Scope{Module: store.NodeModule, Others: moved.Resources}, state, store.OriginDeclared, noServices, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(fact) {
|
||||
t.Fatalf("the moved resource was removed: %+v", outcomeOf(report, "systemd-resolved.fact-suffix"))
|
||||
}
|
||||
if exists(gone) {
|
||||
t.Fatal("a resource no composition holds any more was kept")
|
||||
}
|
||||
if _, still := state.Find("systemd-resolved.fact-suffix"); !still {
|
||||
t.Fatal("the moved resource's record was dropped, so its module's apply would not know it")
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -536,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
|
||||
|
||||
@@ -72,36 +72,36 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,7 +64,6 @@ const (
|
||||
StepStore Step = "store"
|
||||
StepBroker Step = "broker"
|
||||
StepVault Step = "vault"
|
||||
StepCatalogue Step = "catalogue"
|
||||
StepNetwork Step = "network"
|
||||
StepFilter Step = "filter"
|
||||
StepExtras Step = "extras"
|
||||
@@ -89,7 +88,7 @@ var Steps = []Step{
|
||||
// Phase two. The twelve above make a mesh that RUNS; these make one that WORKS — able to
|
||||
// build, to say what it holds, on its network, filtering. They used to be things somebody
|
||||
// typed afterwards, which is how they went missing without anything complaining.
|
||||
StepBase, StepStore, StepBroker, StepVault, StepCatalogue, StepNetwork, StepFilter, StepExtras, StepExport,
|
||||
StepBase, StepStore, StepBroker, StepVault, StepNetwork, StepFilter, StepExtras, StepExport,
|
||||
}
|
||||
|
||||
// Error is a failure, named by the step it happened in.
|
||||
@@ -184,7 +183,7 @@ type Options struct {
|
||||
// SDKSource is where the mesh's shared library is built from. It is published to the package
|
||||
// registry before the base is built, because the base resolves it by version (novox/hq ADR 0076).
|
||||
SDKSource Source
|
||||
// Site is where this machine sits, for the private network's placement.
|
||||
// Site is where this machine sits, for the private network's assignment.
|
||||
Site string
|
||||
// Answers are the choices, answered by flag: name → answer. What a terminal would be asked.
|
||||
Answers map[string]string
|
||||
@@ -750,12 +749,6 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
||||
return result, failed(StepVault, err)
|
||||
}
|
||||
|
||||
// ---- 15. catalogue --------------------------------------------------------------------
|
||||
say("catalogue — the module graph: what is held, what a change reaches, what to rebuild")
|
||||
if err := InstallFromCatalogue(ctx, o, permanentControl, "mesh-catalog", say); err != nil {
|
||||
return result, failed(StepCatalogue, err)
|
||||
}
|
||||
|
||||
// ---- 16. network ----------------------------------------------------------------------
|
||||
say("network — the private network, and this machine's name on it")
|
||||
if err := PlaceOnTheNetwork(ctx, o, permanentControl, rewritten.BrokerAddress, say); err != nil {
|
||||
@@ -822,7 +815,6 @@ func RegistryAndPublish(ctx context.Context, o Options, d Deps, sys system.Syste
|
||||
if err != nil {
|
||||
return Published{}, failed(StepRegistry, err)
|
||||
}
|
||||
|
||||
// ---- 8. publish -----------------------------------------------------------------------
|
||||
say("publish — the control plane's image gets its first manifest digest")
|
||||
// **The image this mesh built, not the one the installer carried.** The carried one is the
|
||||
|
||||
@@ -69,8 +69,9 @@ func adoptTheBus(ctx context.Context, o Options, control controlPlane, bus *decl
|
||||
return nil
|
||||
}
|
||||
|
||||
// meshBuiltImage is an image the mesh built, as it records it: `artifact-store://<module>/<artifact>@sha256:…`.
|
||||
var meshBuiltImage = regexp.MustCompile(`artifact-store://([a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64})`)
|
||||
// meshBuiltImage is an image the mesh built, as it records it in either form it reads (ADR 0605 §9, issue 1966 ENG-4):
|
||||
// `artifact-store://<module>/<artifact>@sha256:…`, or the registry seat's `container-image-registry/<module>/<artifact>@sha256:…`.
|
||||
var meshBuiltImage = regexp.MustCompile(`(?:artifact-store://|container-image-registry/)([a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64})`)
|
||||
|
||||
// theModulesBusImage is the nats module's server image, as the build just made it, pulled from this
|
||||
// mesh's registry: the image that carries the snapshot program.
|
||||
|
||||
@@ -139,4 +139,12 @@ func TestTheBundlesBusRunsTheReleaseTheModulesImageIsBuiltOn(t *testing.T) {
|
||||
if got, want := containerNamed(rewritten.Declaration, "mesh-broker-nats").Image, repository+"@"+digest; got != want {
|
||||
t.Errorf("the bundle raises the bus on %s; the nats module's server is %s (%s)", got, want, from)
|
||||
}
|
||||
|
||||
// The image the mesh built is read in either form it is recorded in (ENG-4).
|
||||
sum := strings.Repeat("c", 64)
|
||||
for _, said := range []string{"artifact-store://nats/server@sha256:" + sum, "container-image-registry/nats/server@sha256:" + sum} {
|
||||
if m := meshBuiltImage.FindStringSubmatch("build 1: " + said); m == nil || m[1] != "nats/server@sha256:"+sum {
|
||||
t.Errorf("%s is read as %v", said, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,35 +237,3 @@ func TestAFileNothingOfTheControllerWritesIsStillRefused(t *testing.T) {
|
||||
t.Fatalf("a file nothing writes was accepted, or refused without its name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's own manifest, as the mesh runs it, passes step 9 (issue 597): in a merge check the
|
||||
// node-engine's check has the controller cloned beside it (MESH_CHECK_BESIDE), where it must be; else a
|
||||
// checkout beside this one; else it skips aloud.
|
||||
//
|
||||
// The checker places the controller beside this repository (mesh-delivery's besideRules, since R4g, issue 1295), so a
|
||||
// MESH_CHECK_BESIDE without a mesh-controller directory, or without its module.json, fails. Only a run with no
|
||||
// MESH_CHECK_BESIDE and no checkout beside this one (a developer's clone alone) skips aloud.
|
||||
func TestTheControllersOwnManifestPassesStepNine(t *testing.T) {
|
||||
var manifest []byte
|
||||
if root := os.Getenv("MESH_CHECK_BESIDE"); root != "" {
|
||||
if info, err := os.Stat(filepath.Join(root, "mesh-controller")); err != nil || !info.IsDir() {
|
||||
t.Fatalf("the controller is not beside this check in MESH_CHECK_BESIDE=%s: the checker places mesh-controller beside the node-engine's check (mesh-delivery besideRules, issue 1295), and this test judges the controller's own manifest", root)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "mesh-controller", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("the controller is not beside this check in MESH_CHECK_BESIDE=%s: %v", root, err)
|
||||
}
|
||||
manifest = raw
|
||||
} else if raw, err := os.ReadFile(filepath.Join("..", "..", "..", "mesh-controller", "module.json")); err == nil {
|
||||
manifest = raw
|
||||
} else {
|
||||
t.Skip("NOT JUDGED: no controller beside this checkout and no MESH_CHECK_BESIDE; the merge check judges it")
|
||||
}
|
||||
wanted, err := theStepNineSecretsOf(t, manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("genesis's step 9 refuses the controller the mesh runs: %v", err)
|
||||
}
|
||||
if wanted["MESH_STORE_INVENTORY"] != "inventory" {
|
||||
t.Errorf("step 9 delivers %v", wanted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,24 @@ import (
|
||||
// stopped genesis at step 17: "this mesh holds no copy of it. Build mesh-tools-go first".
|
||||
|
||||
// genesisModules is every catalogue module genesis installs on its own, read from the steps that
|
||||
// install them: the registry, the build agent, the store, the bus, the vault, the catalogue and the
|
||||
// install them: the registry, the build agent, the store, the bus, the vault and the
|
||||
// filter (extras are the operator's choice and come after the base too, but are not genesis's own).
|
||||
var genesisModules = []string{RegistryModule, BuilderModule, "postgres", BusModule, "mesh-vault", "mesh-catalog", "nftables"}
|
||||
var genesisModules = []string{RegistryModule, BuilderModule, "postgres", BusModule, "mesh-vault", "nftables"}
|
||||
|
||||
// Every genesis module's bundles are compiled in a toolchain the base step builds, read from the real
|
||||
// catalogue's manifests: a module that gains a language genesis does not build fails here, not at a
|
||||
// genesis.
|
||||
func TestTheBaseStepBuildsEveryToolchainAGenesisModuleIsCompiledIn(t *testing.T) {
|
||||
// ADR 0604 section 7: the catalog is the indexer's seat now, so genesis has no catalogue step and installs or takes no
|
||||
// mesh-catalog module.
|
||||
for _, step := range Steps {
|
||||
if step == "catalogue" {
|
||||
t.Fatal("genesis still has a catalogue step")
|
||||
}
|
||||
}
|
||||
if genesisTakes["mesh-catalog"] {
|
||||
t.Fatal("an adopted genesis still takes mesh-catalog")
|
||||
}
|
||||
built := map[string]bool{}
|
||||
for _, tc := range genesisToolchains {
|
||||
built[tc.Language] = true
|
||||
|
||||
@@ -146,7 +146,7 @@ func InstallFromCatalogue(ctx context.Context, o Options, control controlPlane,
|
||||
//
|
||||
// **Assigning is not being on the network** — a lesson paid for: the module installed, the names
|
||||
// file was written with no names in it, and everything reported success, because nobody had said
|
||||
// where this machine IS. So placement is part of the step, not a separate act.
|
||||
// where this machine IS. So assignment is part of the step, not a separate act.
|
||||
func PlaceOnTheNetwork(ctx context.Context, o Options, control controlPlane,
|
||||
brokerAddress string, say func(string)) error {
|
||||
|
||||
|
||||
@@ -69,17 +69,24 @@ func PublishControlPlane(ctx context.Context, o Options, d Deps, imageID string,
|
||||
func publishAs(ctx context.Context, o Options, d Deps, imageID, repository string,
|
||||
say func(string)) (Published, error) {
|
||||
|
||||
remote := o.Registry + "/" + repository
|
||||
// Pushed to the store's own loopback with no credential (StoreLoopback); recorded under the seat's reach, the
|
||||
// address every node pulls by, with the digest the store gave.
|
||||
loopback, err := StoreLoopback(o.Catalogue)
|
||||
if err != nil {
|
||||
return Published{}, err
|
||||
}
|
||||
remote := loopback + "/" + repository
|
||||
out := Published{Tagged: remote + ":" + genesisTag}
|
||||
reach := func(digest string) string { return o.Registry + "/" + repository + strings.TrimPrefix(digest, remote) }
|
||||
|
||||
// Asked first. A digest already served is a fact about the registry, and re-pushing an image
|
||||
// the registry already holds is asking it to store what it already has under the name it
|
||||
// already has.
|
||||
if held, err := digestOf(ctx, o, d, remote); err != nil {
|
||||
if held, err := digestOf(ctx, o, d, loopback, repository); err != nil {
|
||||
return out, err
|
||||
} else if held != "" {
|
||||
out.Reference, out.Already = held, true
|
||||
say(" already published " + held)
|
||||
out.Reference, out.Already = reach(held), true
|
||||
say(" already published " + out.Reference)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -93,12 +100,12 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin
|
||||
"over the mesh's own network, which is already the encrypted and authenticated "+
|
||||
"thing. A runtime refusing it for being insecure is refusing a registry on %s, "+
|
||||
"which it does not do for a loopback address",
|
||||
out.Tagged, err, o.Registry)
|
||||
out.Tagged, err, loopback)
|
||||
}
|
||||
|
||||
// Read back, from the registry's own answer rather than computed here. What matters is what
|
||||
// the registry will serve for that reference, and only it can say (novox/hq ADR 0018).
|
||||
pinned, err := digestOf(ctx, o, d, remote)
|
||||
pinned, err := digestOf(ctx, o, d, loopback, repository)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -107,10 +114,10 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin
|
||||
"%s was pushed and the registry does not serve it.\n"+
|
||||
"The next step names this module by the digest this was supposed to produce, so "+
|
||||
"there is nothing to name. Check `docker push` and "+
|
||||
"http://%s/v2/%s/tags/list", out.Tagged, o.Registry, repository)
|
||||
"http://%s/v2/%s/tags/list", out.Tagged, loopback, repository)
|
||||
}
|
||||
out.Reference = pinned
|
||||
say(" published " + pinned)
|
||||
out.Reference = reach(pinned)
|
||||
say(" published " + out.Reference)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -121,13 +128,13 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin
|
||||
// whether the registry still has it — a registry whose volume was recreated would leave the
|
||||
// runtime remembering a digest nothing serves, and the module registered against it would pin the
|
||||
// mesh to an image that cannot be pulled.
|
||||
func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, error) {
|
||||
func digestOf(ctx context.Context, o Options, d Deps, store, repository string) (string, error) {
|
||||
remote := store + "/" + repository
|
||||
asking, cancel := context.WithTimeout(ctx, o.Timeout)
|
||||
status, body, err := d.Fetch(asking,
|
||||
"http://"+o.Registry+"/v2/"+ControlPlaneRepository+"/tags/list")
|
||||
status, body, err := d.Fetch(asking, "http://"+store+"/v2/"+repository+"/tags/list")
|
||||
cancel()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot ask the registry at %s what it holds: %w", o.Registry, err)
|
||||
return "", fmt.Errorf("cannot ask the registry at %s what it holds: %w", store, err)
|
||||
}
|
||||
if status == http.StatusNotFound {
|
||||
// Nothing has ever been pushed under this name. An answer, not a failure.
|
||||
@@ -135,14 +142,14 @@ func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, er
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return "", fmt.Errorf("the registry answered %d when asked what it holds for %s",
|
||||
status, ControlPlaneRepository)
|
||||
status, repository)
|
||||
}
|
||||
var listed struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &listed); err != nil {
|
||||
return "", fmt.Errorf("the registry's answer about %s is not readable: %w",
|
||||
ControlPlaneRepository, err)
|
||||
repository, err)
|
||||
}
|
||||
if !contains(listed.Tags, genesisTag) {
|
||||
return "", nil
|
||||
|
||||
@@ -22,7 +22,8 @@ func publishing(t *testing.T, fetch func(string) (int, string, error),
|
||||
// Each value named before it is returned: a return of several composite literals spanning lines is laid
|
||||
// out differently by the gofmt of one Go release and the next, and the build seat's is the one that
|
||||
// judges (novox/hq issue 286).
|
||||
options := Options{Registry: "127.0.0.1:5000", Timeout: time.Second, Wait: 0}
|
||||
options := Options{Registry: "127.0.0.1:5000", Timeout: time.Second, Wait: 0,
|
||||
Catalogue: catalogueWith(t, RegistryModule, upstreamRegistryManifest)}
|
||||
deps := Deps{
|
||||
Run: runtime.run,
|
||||
Fetch: func(_ context.Context, url string) (int, string, error) {
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegistryModule is the module that provides the mesh's artifact store.
|
||||
// RegistryModule is the module that holds the mesh's artifact store.
|
||||
//
|
||||
// **Named for the software, not the job.** The job is `artifact-store`, which is the provision this
|
||||
// module offers; the software is Distribution, the OCI reference implementation. Calling the module
|
||||
// **Named for the software, not the job.** The job is the artifact store, which this module holds as
|
||||
// the seat container-image-registry (novox/hq ADR 0632 §1); the software is Distribution, the OCI reference implementation. Calling the module
|
||||
// `registry` named neither — and promised that any registry could sit there, which is the false
|
||||
// genericity the naming rule forbids (novox/hq ADR 0040).
|
||||
const RegistryModule = "distribution"
|
||||
@@ -95,9 +95,14 @@ func InstallRegistry(ctx context.Context, o Options, d Deps, control controlPlan
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// waitForTheRegistry asks `/v2/` until it answers.
|
||||
// waitForTheRegistry asks the store's own `/v2/`, on its loopback (StoreLoopback), until it answers: the pushes that
|
||||
// follow go there, and the store's front is not served yet on a fresh mesh.
|
||||
func waitForTheRegistry(ctx context.Context, d Deps, o Options, say func(string)) (int, error) {
|
||||
where := "http://" + o.Registry + "/v2/"
|
||||
loopback, err := StoreLoopback(o.Catalogue)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
where := "http://" + loopback + "/v2/"
|
||||
|
||||
deadline := time.Now().Add(o.Wait)
|
||||
var last string
|
||||
@@ -207,3 +212,43 @@ func containerIn(manifest []byte, id string) (name, image string, err error) {
|
||||
"the %s module declares no container %q, so the installer does not know what to wait for. "+
|
||||
"It declares: %s", m.Module, id, strings.Join(containers, ", "))
|
||||
}
|
||||
|
||||
// storeContainerPort is the port the registry image listens on inside its container (Distribution's own default).
|
||||
const storeContainerPort = "5000"
|
||||
|
||||
// StoreLoopback is host:port where this machine reaches the store's container itself, read from the port its manifest
|
||||
// publishes for it: on a fresh mesh the bootstrap pushes the holders' own images there, with no credential, because the
|
||||
// store's front (distribution's program, a bundle the mesh builds) is not served until the builder has built it (the
|
||||
// publish contract allows a push on the store's own node to its loopback). A port published on every address is reached
|
||||
// on 127.0.0.1. What the bootstrap records is named under the seat's reach (Options.Registry), never this address.
|
||||
func StoreLoopback(catalogue string) (string, error) {
|
||||
manifest, err := readManifest(catalogue, RegistryModule)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var m struct {
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Ports []string `json:"ports"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifest, &m); err != nil {
|
||||
return "", fmt.Errorf("the %s manifest is not readable as JSON: %w", RegistryModule, err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r.ID != registryResource {
|
||||
continue
|
||||
}
|
||||
for _, p := range r.Ports {
|
||||
parts := strings.Split(strings.SplitN(p, "/", 2)[0], ":")
|
||||
switch {
|
||||
case len(parts) == 3 && parts[2] == storeContainerPort:
|
||||
return parts[0] + ":" + parts[1], nil
|
||||
case len(parts) == 2 && parts[1] == storeContainerPort:
|
||||
return "127.0.0.1:" + parts[0], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("the %s module's container %q publishes no port for the store's %s, so the bootstrap cannot "+
|
||||
"reach the store to push to it", RegistryModule, registryResource, storeContainerPort)
|
||||
}
|
||||
|
||||
@@ -226,8 +226,15 @@ func TestARangeTheRuntimeTrustsCoversAnAddressInIt(t *testing.T) {
|
||||
// moving the trust after the push, or dropping it, fails this test.
|
||||
func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
|
||||
o, runtime, sys := trustSetup(t, `{"data-root":"/srv/docker"}`)
|
||||
o.Catalogue = catalogueWith(t, RegistryModule, upstreamRegistryManifest)
|
||||
// The store as distribution's manifest declares it: its container on this machine's loopback, its front not yet served,
|
||||
// and its state directory, where no credential is ever written (design 51 *On a fresh mesh*, a design limit of issue 1966).
|
||||
stateDir := t.TempDir()
|
||||
o.Catalogue = catalogueWith(t, RegistryModule, strings.NewReplacer(
|
||||
`"path": "/var/lib/mesh/registry"`, `"path": "`+stateDir+`"`,
|
||||
`"ports": ["5000:5000"]`, `"ports": ["127.0.0.1:5001:5000"]`).Replace(upstreamRegistryManifest))
|
||||
o.Registry = "192.0.2.10:5100"
|
||||
const loopback = "127.0.0.1:5001"
|
||||
var fetched []string
|
||||
|
||||
pushed := false
|
||||
mesh := aMeshThatAgrees(nil)
|
||||
@@ -241,7 +248,8 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
|
||||
case "tag":
|
||||
return "", nil
|
||||
case "push":
|
||||
trusted := false
|
||||
// The runtime trusts a loopback registry with no configuration; any other only once told.
|
||||
trusted := strings.HasPrefix(args[1], "127.0.0.1:")
|
||||
for _, n := range runtime.trusted {
|
||||
trusted = trusted || strings.HasPrefix(args[1], n+"/")
|
||||
}
|
||||
@@ -251,12 +259,13 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
|
||||
pushed = true
|
||||
return "", nil
|
||||
case "image":
|
||||
return `["` + o.Registry + `/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil
|
||||
return `["` + loopback + `/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil
|
||||
}
|
||||
}
|
||||
return mesh(name, args)
|
||||
}
|
||||
d := Deps{Run: run, Fetch: func(_ context.Context, url string) (int, string, error) {
|
||||
fetched = append(fetched, url)
|
||||
if strings.HasSuffix(url, "/v2/") || pushed {
|
||||
return http.StatusOK, `{"name":"mesh-controller","tags":["genesis"]}`, nil
|
||||
}
|
||||
@@ -287,6 +296,23 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
|
||||
t.Errorf("the trust was not written (%d) and confirmed (%d) before the first tag or push (%d): %v",
|
||||
reloaded, confirmed, firstPush, runtime.commands)
|
||||
}
|
||||
// The push went to the store on its loopback, and nothing the run asked or pushed reached the seat's reach; the
|
||||
// reference is named under the reach all the same, and no credential file was written for the store's holder.
|
||||
for _, c := range runtime.commands {
|
||||
if strings.HasPrefix(c, "docker tag") || strings.HasPrefix(c, "docker push") {
|
||||
if !strings.Contains(c, " "+loopback+"/mesh-controller:") {
|
||||
t.Errorf("a push not to the loopback store: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, u := range fetched {
|
||||
if !strings.HasPrefix(u, "http://"+loopback+"/v2/") {
|
||||
t.Errorf("the run asked %s, not the loopback store", u)
|
||||
}
|
||||
}
|
||||
if entries, err := os.ReadDir(stateDir); err != nil || len(entries) != 0 {
|
||||
t.Errorf("the run wrote into the store's state directory: %v (%v)", entries, err)
|
||||
}
|
||||
if fmt.Sprint(result.RegistryTrusted) != "[192.0.2.10:5100 anchor.internal:5100]" {
|
||||
t.Errorf("the result says it trusted %v", result.RegistryTrusted)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package buckets holds the names of the bus buckets the node-engine reads and writes, and how many revisions it keeps of
|
||||
// each. A contract carries the local name of a state, not the module that holds it (ADR 0625 §4, §7); the node-engine
|
||||
// reads through grants composed in code, so the holder's name is kept here, in its own code (ADR 0634 §1, finding F4).
|
||||
package buckets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
vault "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
wants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// The node-engine's own states and the have state, written by every node's engine for its own keys.
|
||||
const (
|
||||
// Have holds a module's have on a node, `<node>.<module>`, and the node's own facts, `<node>.node`.
|
||||
Have = "node-engine_have"
|
||||
// Data holds a module's data items on a node.
|
||||
Data = "mesh-host_data"
|
||||
// Health holds a node's own health report.
|
||||
Health = "mesh-host_health"
|
||||
)
|
||||
|
||||
// HaveHistory is the number of revisions the have bucket keeps per key.
|
||||
const HaveHistory = 20
|
||||
|
||||
// The states of the assigner seat's holder and of the wants seat's holder that the engine reads, named by the holder's
|
||||
// module as every state is (ADR 0634 §1, finding F4: the holder's name is kept here, in the engine's code).
|
||||
const (
|
||||
// Assignments holds a node's assignments, `<node>.node`, and each of its modules, `<node>.<module>`.
|
||||
Assignments = AssignerHolder + "_" + seat.StateAssignments
|
||||
// ProposalCandidates holds a node's candidate of a proposal, `<proposal>.<node>`.
|
||||
ProposalCandidates = AssignerHolder + "_" + seat.StateCandidates
|
||||
// Wants holds what each node should run, `want.<node>.<module>` and `want.<node>.node`.
|
||||
Wants = AssignerHolder + "_" + wants.StateWants
|
||||
|
||||
// AssignerHolder is the module holding the seat mesh-module-assigner and the seat mesh-wants.
|
||||
AssignerHolder = "mesh-delivery-assigner"
|
||||
)
|
||||
|
||||
// The state of the provisioner seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4).
|
||||
const (
|
||||
// ProvisionerHolder is the module holding the seat mesh-provisioner; Provisions its state provisions, which the engine
|
||||
// watches for the key of a provision it fills (`<node>.>`), ProvisionsRead the name a manifest's reads gives it.
|
||||
ProvisionerHolder = "mesh-provisioner"
|
||||
Provisions = ProvisionerHolder + "_provisions"
|
||||
ProvisionsRead = ProvisionerHolder + ".provisions"
|
||||
)
|
||||
|
||||
// The states of the catalogue seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4).
|
||||
const (
|
||||
// Modules holds a module's source, `<module>`; Manifests a manifest by its digest; Verdicts the judge's verdict of a
|
||||
// manifest by its digest; Candidates a group's candidate, by the group's key part (CandidateKey).
|
||||
Modules = CatalogHolder + "_modules"
|
||||
Manifests = CatalogHolder + "_manifests"
|
||||
Verdicts = CatalogHolder + "_verdicts"
|
||||
Candidates = CatalogHolder + "_candidates"
|
||||
|
||||
// CatalogHolder is the module holding the seat mesh-catalog.
|
||||
CatalogHolder = "mesh-delivery-indexer"
|
||||
)
|
||||
|
||||
// CandidateKey is the key of a group's candidate in Candidates: the group as a key part, a branch such as `fix/1.2`
|
||||
// escaped so it keys.
|
||||
func CandidateKey(group string) (string, error) {
|
||||
if group == "" {
|
||||
return "", errors.New("buckets: the group name is empty")
|
||||
}
|
||||
return catalog.CandidatesByGroupKey(keys.EscapeName(group)), nil
|
||||
}
|
||||
|
||||
// The states of the vault seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4).
|
||||
const (
|
||||
// Signers holds the vault's public signing halves; NodeLogins each node's logins, `<node>`.
|
||||
Signers = vaultHolder + "_" + vault.StateSigners
|
||||
NodeLogins = vaultHolder + "_" + vault.StateNodeLogins
|
||||
// SignersRead and NodeLoginsRead are the states as a manifest's reads names them.
|
||||
SignersRead = vaultHolder + "." + vault.StateSigners
|
||||
NodeLoginsRead = vaultHolder + "." + vault.StateNodeLogins
|
||||
|
||||
vaultHolder = "mesh-vault"
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
package buckets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The engine's own states are named <module>_<state> by the manifest; a constant that drifts from it reads and writes a
|
||||
// bucket nobody else knows (ADR 0634 §1, finding F4: the holder's name is kept in code and held to the manifest by this row).
|
||||
func TestTheOwnStatesAreTheManifestsStates(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
State []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declared := map[string]bool{}
|
||||
for _, s := range m.State {
|
||||
declared[m.Module+"_"+s.Name] = true
|
||||
}
|
||||
for name, bucket := range map[string]string{"data": Data, "health": Health} {
|
||||
if !declared[bucket] {
|
||||
t.Errorf("the bucket %s for the state %s is not one the manifest of %s declares (%v)", bucket, name, m.Module, declared)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A group's candidate key is its name as a key part: a branch with a slash keeps it, a dot is escaped, and an empty group does not.
|
||||
func TestACandidateKeyEscapesTheGroup(t *testing.T) {
|
||||
for _, group := range []string{"mesh-sdk#7", "fix/940-no-retire", "fix/1.2"} {
|
||||
key, err := CandidateKey(group)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", group, err)
|
||||
}
|
||||
if strings.Contains(key, ".") {
|
||||
t.Errorf("the key %q of %q holds a dot", key, group)
|
||||
}
|
||||
if back, err := keys.UnescapeName(key); err != nil || back != group {
|
||||
t.Errorf("%q came back as %q (%v)", group, back, err)
|
||||
}
|
||||
}
|
||||
if _, err := CandidateKey(""); err == nil {
|
||||
t.Error("a key was made for no group")
|
||||
}
|
||||
}
|
||||
|
||||
// The holders' buckets are the names the grants part grants and the holders' manifests declare: no client carries a
|
||||
// holder (ADR 0625 §4), so these rows are the one place the node-engine's code says them.
|
||||
func TestTheHoldersBucketsAreTheirStatesBuckets(t *testing.T) {
|
||||
for name, c := range map[string][2]string{
|
||||
"Assignments": {Assignments, "mesh-delivery-assigner_assignments"}, "ProposalCandidates": {ProposalCandidates, "mesh-delivery-assigner_candidates"},
|
||||
"Wants": {Wants, "mesh-delivery-assigner_wants"}, "Modules": {Modules, "mesh-delivery-indexer_modules"},
|
||||
"Manifests": {Manifests, "mesh-delivery-indexer_manifests"}, "Verdicts": {Verdicts, "mesh-delivery-indexer_verdicts"},
|
||||
"Candidates": {Candidates, "mesh-delivery-indexer_candidates"}, "Provisions": {Provisions, "mesh-provisioner_provisions"},
|
||||
"ProvisionsRead": {ProvisionsRead, "mesh-provisioner.provisions"},
|
||||
} {
|
||||
if c[0] != c[1] {
|
||||
t.Errorf("%s = %q, want %q", name, c[0], c[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package bucketstest writes the values of the states the engine only reads (a want, a assignment candidate) as their
|
||||
// holders do, for the tests that stand in for the holders.
|
||||
package bucketstest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
)
|
||||
|
||||
// Version is the value version the holders write.
|
||||
const Version = 1
|
||||
|
||||
// Want is a module's want as the wants seat's holder writes it.
|
||||
func Want(w meshwants.WantsByWantNodeModuleValue) ([]byte, error) {
|
||||
w.V = Version
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
// NodeWant is a node's own want as the wants seat's holder writes it.
|
||||
func NodeWant(w meshwants.WantsByWantNodeNodeValue) ([]byte, error) {
|
||||
w.V = Version
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
// Candidate is a node's candidate of a proposal as the assignment seat's holder writes it.
|
||||
func Candidate(c meshmoduleassigner.CandidatesByProposalNodeValue) ([]byte, error) {
|
||||
c.V = Version
|
||||
if c.Assignments != nil {
|
||||
p := *c.Assignments
|
||||
p.V = Version
|
||||
c.Assignments = &p
|
||||
}
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// GroupCandidate is a group's candidate as the catalogue seat's holder writes it.
|
||||
func GroupCandidate(c meshcatalog.CandidatesByGroupValue) ([]byte, error) {
|
||||
c.V = Version
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// Pull is one pull request of a candidate, from its head.
|
||||
func Pull(head meshdeliveryclient.Head, modules []string, fps map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue) meshcatalog.CandidatesByGroupValuePullsItem {
|
||||
return meshcatalog.CandidatesByGroupValuePullsItem{Repository: head.Repository, Number: int64(head.Number), Head: head.Head, Modules: modules, Fps: fps}
|
||||
}
|
||||
|
||||
// Raw is each value as JSON, as a composer case carries verdicts and assignments.
|
||||
func Raw[V any](in map[string]V) map[string]json.RawMessage {
|
||||
out := map[string]json.RawMessage{}
|
||||
for k, v := range in {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
out[k] = raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/registryforms"
|
||||
)
|
||||
|
||||
// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as
|
||||
// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own
|
||||
// (`<node>.node`), by key (engcomp.Key). Each resource says its module, whatever the node reported it reads.
|
||||
// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module
|
||||
// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
|
||||
// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own,
|
||||
// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed
|
||||
// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are unsigned
|
||||
// (ADR 0593 §6) and not yet by digest (Composed.ByDigest).
|
||||
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]engcomp.Composed, skipped []string, err error) {
|
||||
out, skipped, _, err = compositions(in, node, fps, mode, false)
|
||||
return out, skipped, err
|
||||
}
|
||||
|
||||
// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's
|
||||
// sealed copy, a vault box, the certificate, the bus's user list) or a port it does not give: what a send would make,
|
||||
// never what may be applied.
|
||||
var ErrStoodIn = errors.New("compose: the composition stands in for what its input does not carry")
|
||||
|
||||
// BusUsersStandIn is what a composition says it stood in for when its input carries no bus user list.
|
||||
const BusUsersStandIn = "the bus's user list"
|
||||
|
||||
// CompositionsApplied is Compositions for a node that applies what it composed itself (novox/hq ADR 0547 §2, issue
|
||||
// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with
|
||||
// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list
|
||||
// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself.
|
||||
func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]engcomp.Composed, []string, error) {
|
||||
out, skipped, _, err := compositions(in, node, fps, Reading, true)
|
||||
return out, skipped, err
|
||||
}
|
||||
|
||||
// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a
|
||||
// assignment candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module
|
||||
// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted,
|
||||
// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a
|
||||
// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes
|
||||
// as CompositionsApplied composes it: the modes differ only in an own secret never issued.
|
||||
func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]engcomp.Composed, skipped, stood []string, err error) {
|
||||
return compositions(in, node, fps, Foreseeing, false)
|
||||
}
|
||||
|
||||
// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user
|
||||
// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses
|
||||
// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126).
|
||||
func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]engcomp.Composed, skipped, stood []string, err error) {
|
||||
return compositions(in, node, fps, mode, false)
|
||||
}
|
||||
|
||||
// Folds says whether a module the node runs inside its own composition (inOwn, Node.InOwn) is folded there at the
|
||||
// fingerprint fp it is composed at: only at no fingerprint of the new form. At one (src2:) it has a composition of its
|
||||
// own, composed from its build, so every reader of a build (frombus) takes the build in exactly where this says it is
|
||||
// not folded (issue 1585: nats on novox, folded by the reader and composed at src2: here, refused for its server
|
||||
// artifact no build had published).
|
||||
func Folds(inOwn bool, fp string) bool {
|
||||
return inOwn && !strings.HasPrefix(fp, meshcatalogclient.SourcePrefix2)
|
||||
}
|
||||
|
||||
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]engcomp.Composed, skipped, stood []string, err error) {
|
||||
reading := in
|
||||
reading.Nodes = append([]Node(nil), in.Nodes...)
|
||||
found := false
|
||||
inOwn := map[string]bool{}
|
||||
held := map[string]bool{}
|
||||
for i := range reading.Nodes {
|
||||
if reading.Nodes[i].Name == node {
|
||||
reading.Nodes[i].ReadsModules = true
|
||||
found = true
|
||||
for m, ok := range reading.Nodes[i].InOwn {
|
||||
inOwn[m] = ok
|
||||
}
|
||||
for m, ok := range reading.Nodes[i].Held {
|
||||
held[m] = ok
|
||||
}
|
||||
}
|
||||
}
|
||||
// The private network's module is built by nobody: no fingerprint of the new form ever names it.
|
||||
inOwn[overlay.Name] = true
|
||||
folds := func(module string) bool {
|
||||
return module != "" && Folds(inOwn[module], fps[module])
|
||||
}
|
||||
if !found {
|
||||
return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
|
||||
}
|
||||
// A folded module built by a build of the old form (Node.RunsOwn) is left to the node: composed from what runs of it
|
||||
// (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published
|
||||
// ports, the paths it owns) is what runs, and its resources are then the ones the node runs.
|
||||
carried := map[string]bool{}
|
||||
var runsOwn *engcomp.Composed
|
||||
for i := range reading.Nodes {
|
||||
n := &reading.Nodes[i]
|
||||
if n.Name != node || n.RunsOwn == nil {
|
||||
continue
|
||||
}
|
||||
runsOwn = n.RunsOwn
|
||||
candidates := map[string]bool{}
|
||||
for m := range inOwn {
|
||||
candidates[m] = true
|
||||
}
|
||||
for m := range held {
|
||||
candidates[m] = true
|
||||
}
|
||||
for m := range candidates {
|
||||
// A held module is carried as it runs wherever the node's own runs it, folded or leaving the fold for a
|
||||
// composition of its own (its want new, its names still unset): its stub is never what the node's own applies,
|
||||
// and what the node's own applies never drops what runs of it (issue 1126, review of the apply; issue 1945).
|
||||
heldHere := held[m] && RunsAnyOf(runsOwn, m)
|
||||
if !folds(m) && !heldHere {
|
||||
continue
|
||||
}
|
||||
man, ok := n.Kept[m]
|
||||
if !ok {
|
||||
man, ok = reading.Catalogue[m]
|
||||
}
|
||||
if !ok || (!NamesBuild(man) && !held[m]) {
|
||||
continue
|
||||
}
|
||||
carried[m] = true
|
||||
kept := make(map[string]catalogue.Manifest, len(n.Kept)+1)
|
||||
for k, v := range n.Kept {
|
||||
kept[k] = v
|
||||
}
|
||||
kept[m] = Carried(man, m, runsOwn)
|
||||
n.Kept = kept
|
||||
}
|
||||
}
|
||||
// A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs
|
||||
// (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before.
|
||||
for i := range reading.Nodes {
|
||||
n := &reading.Nodes[i]
|
||||
if n.Name != node || n.RunsTools == nil {
|
||||
continue
|
||||
}
|
||||
kept := make(map[string]catalogue.Manifest, len(n.Kept))
|
||||
for m, man := range n.Kept {
|
||||
if man.Stubbed && man.RunsAs == "" {
|
||||
man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m])
|
||||
}
|
||||
kept[m] = man
|
||||
}
|
||||
n.Kept = kept
|
||||
}
|
||||
isCarried := func(module, id string) bool {
|
||||
if module == "" {
|
||||
module, _ = declaration.ModuleGuess(id)
|
||||
}
|
||||
return carried[module]
|
||||
}
|
||||
d, problems, err := ComposeIn(reading, node, mode)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems)
|
||||
}
|
||||
for _, x := range d.StoodIn {
|
||||
// The bus's user list is the bus node's own to follow, written alone and never from a composition the node
|
||||
// made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied.
|
||||
if x != BusUsersStandIn {
|
||||
stood = append(stood, x)
|
||||
}
|
||||
}
|
||||
stood = append(stood, d.Foreseen...)
|
||||
for _, p := range d.ForeseenPorts {
|
||||
stood = append(stood, "port "+p)
|
||||
}
|
||||
sort.Strings(stood)
|
||||
stood = slices.Compact(stood) // an own secret is both stood in for and foreseen
|
||||
if applied && len(stood) > 0 {
|
||||
return nil, nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", "))
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var envelope struct {
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
Adoption *declaration.Adoption `json:"adoption"`
|
||||
Filled []string `json:"filled_files"`
|
||||
Provisioner string `json:"provisioner_account"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var kept []json.RawMessage
|
||||
keptPaths := map[string]bool{}
|
||||
foldedPaths := map[string]bool{}
|
||||
left := map[string]bool{}
|
||||
for _, raw := range envelope.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if isCarried(head.Module, head.ID) {
|
||||
continue // what the stub composed of it: the node's own carries what runs of it instead
|
||||
}
|
||||
if folds(head.Module) {
|
||||
// Into the node's own, in its place among the node's resources, its module taken off (mesh-controller's
|
||||
// compositionsOf): the node's own composition is then the controller's, byte for byte.
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
delete(fields, "module")
|
||||
if raw, err = json.Marshal(fields); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if head.Type == string(declaration.TypeFile) {
|
||||
foldedPaths[head.Path] = true
|
||||
}
|
||||
kept = append(kept, raw)
|
||||
continue
|
||||
}
|
||||
if _, ok := fps[head.Module]; head.Module != "" && !ok {
|
||||
left[head.Module] = true
|
||||
continue
|
||||
}
|
||||
if head.Type == string(declaration.TypeFile) {
|
||||
keptPaths[head.Path] = true
|
||||
}
|
||||
kept = append(kept, raw)
|
||||
}
|
||||
// What runs of each carried module, in the node's own composition, each after the resource it runs after.
|
||||
if len(carried) > 0 {
|
||||
var err error
|
||||
if kept, foldedPaths, err = carry(kept, foldedPaths, runsOwn, isCarried); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
for _, p := range runsOwn.Filled {
|
||||
if foldedPaths[p] && !slices.Contains(envelope.Filled, p) {
|
||||
envelope.Filled = append(envelope.Filled, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A filled file of a module's own composition goes with that module (SplitComposed); one of a folded module goes with
|
||||
// the node's own, after them, in the declaration's order; one of a module left out is dropped.
|
||||
var filled, ownFilled []string
|
||||
for _, p := range envelope.Filled {
|
||||
switch {
|
||||
case foldedPaths[p]:
|
||||
ownFilled = append(ownFilled, p)
|
||||
case keptPaths[p]:
|
||||
filled = append(filled, p)
|
||||
}
|
||||
}
|
||||
out, err = engcomp.Split(node, kept, fps, filled,
|
||||
engcomp.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if len(ownFilled) > 0 {
|
||||
own, err := engcomp.NodeKey(node)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
c := out[own]
|
||||
c.Filled = append(c.Filled, ownFilled...)
|
||||
out[own] = c
|
||||
}
|
||||
for key, c := range out {
|
||||
if held[c.Module] {
|
||||
delete(out, key) // its stub's: the node keeps what it last applied of it (Node.Held)
|
||||
continue
|
||||
}
|
||||
if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" {
|
||||
c.Data = len(m.DataItems()) > 0
|
||||
out[key] = c
|
||||
}
|
||||
}
|
||||
for m := range left {
|
||||
skipped = append(skipped, m)
|
||||
}
|
||||
sort.Strings(skipped)
|
||||
return out, skipped, stood, nil
|
||||
}
|
||||
|
||||
// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names.
|
||||
func RunsAnyOf(runs *engcomp.Composed, module string) bool {
|
||||
if runs == nil {
|
||||
return false
|
||||
}
|
||||
for _, raw := range runs.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
if json.Unmarshal(raw, &head) != nil {
|
||||
continue
|
||||
}
|
||||
owner := head.Module
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(head.ID)
|
||||
}
|
||||
if owner == module {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's
|
||||
// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it
|
||||
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
|
||||
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
|
||||
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
|
||||
func ToolsRunning(running []engcomp.Composed) map[string][]catalogue.Bundle {
|
||||
type resource struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
var env map[string]string
|
||||
out := map[string][]catalogue.Bundle{}
|
||||
for _, c := range running {
|
||||
for _, raw := range c.Resources {
|
||||
var r resource
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
continue
|
||||
}
|
||||
owner := r.Module
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(r.ID)
|
||||
}
|
||||
switch {
|
||||
case r.Type == "process" && owner != "" && r.ID == owner+"."+resolution.RuntimeProcessID():
|
||||
// The tool runner's process, by its id alone: `<its module>.runtime` (resolutionRuntimeProcess).
|
||||
env = r.Env
|
||||
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")):
|
||||
name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID(""))
|
||||
if r.Path == resolution.BundlePath(owner, name) {
|
||||
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: registryforms.Forms.Recorded(r.Source), Digest: r.Digest})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return nil
|
||||
}
|
||||
var words map[string]map[string]string
|
||||
_ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words)
|
||||
for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") {
|
||||
module, path, ok := strings.Cut(entry, "=")
|
||||
if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") {
|
||||
continue
|
||||
}
|
||||
name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/")
|
||||
bundles := out[module]
|
||||
i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name })
|
||||
if !ok || i < 0 {
|
||||
continue // no archive of it runs: nothing to load
|
||||
}
|
||||
var own map[string]string
|
||||
for w, v := range words[module] {
|
||||
if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces {
|
||||
continue // the mesh's to say, composed again
|
||||
}
|
||||
if own == nil {
|
||||
own = map[string]string{}
|
||||
}
|
||||
own[w] = v
|
||||
}
|
||||
bundles[i].Loads = append(bundles[i].Loads, load)
|
||||
bundles[i].Env = own
|
||||
}
|
||||
for _, bundles := range out {
|
||||
sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the
|
||||
// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build
|
||||
// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone.
|
||||
func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle {
|
||||
var out []catalogue.Bundle
|
||||
for _, b := range runs {
|
||||
if len(b.Loads) == 0 {
|
||||
for _, built := range man.Bundles {
|
||||
if built.Name == b.Name {
|
||||
b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary
|
||||
}
|
||||
}
|
||||
if man.Build != nil {
|
||||
run := resolution.RunByAResource(man)
|
||||
for _, a := range man.Build.Artifacts {
|
||||
if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
|
||||
b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot
|
||||
// compose such a module folded into its own composition: the build of the old form it runs is held by the controller
|
||||
// alone (issue 1126).
|
||||
func NamesBuild(m catalogue.Manifest) bool {
|
||||
if m.Build != nil {
|
||||
return true
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, ok := r["artifact"]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Stub is m with nothing to build and no resource but its directories: what it provides, requires, serves and grants
|
||||
// still shape the rest of the composition, the places its own secrets, data and grants name (${dir:…}) still resolve,
|
||||
// and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox). A module refused in a
|
||||
// node's answer is composed so; a folded one the node runs is Carried.
|
||||
func Stub(m catalogue.Manifest) catalogue.Manifest {
|
||||
// The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8).
|
||||
if m.OwnsPaths == nil {
|
||||
m.OwnsPaths = resolution.DeclaredPaths(m)
|
||||
}
|
||||
m.Build = nil
|
||||
// What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed).
|
||||
m.Stubbed = true
|
||||
// Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node
|
||||
// refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its
|
||||
// items stay, and the templates they render through: a held module's contributions to other modules' collections
|
||||
// (seat contributions, shell blocks, seat data) still reach their collectors.
|
||||
if m.Handed != nil {
|
||||
h := *m.Handed
|
||||
h.Templates = nil
|
||||
m.Handed = &h
|
||||
}
|
||||
var dirs []map[string]any
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "directory" {
|
||||
dirs = append(dirs, r)
|
||||
}
|
||||
}
|
||||
m.Resources = dirs
|
||||
return m
|
||||
}
|
||||
|
||||
// Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126,
|
||||
// C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their
|
||||
// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it
|
||||
// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The
|
||||
// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes
|
||||
// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and
|
||||
// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the
|
||||
// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them
|
||||
// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that
|
||||
// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none
|
||||
// of is its Stub, as before.
|
||||
func Carried(m catalogue.Manifest, module string, runs *engcomp.Composed) catalogue.Manifest {
|
||||
if runs == nil {
|
||||
return Stub(m)
|
||||
}
|
||||
var taken []map[string]any
|
||||
runsAny := false
|
||||
ids := map[string]bool{}
|
||||
for _, raw := range runs.Resources {
|
||||
var r map[string]any
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
owner, _ := r["module"].(string)
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(id)
|
||||
}
|
||||
if owner != module {
|
||||
continue
|
||||
}
|
||||
runsAny = true
|
||||
if typ := r["type"]; typ != "container" && typ != "directory" {
|
||||
continue
|
||||
}
|
||||
r["id"] = strings.TrimPrefix(id, module+".")
|
||||
delete(r, "module")
|
||||
ids[r["id"].(string)] = true
|
||||
taken = append(taken, r)
|
||||
}
|
||||
if !runsAny {
|
||||
return Stub(m)
|
||||
}
|
||||
stub := Stub(m)
|
||||
for _, r := range stub.Resources {
|
||||
if id, _ := r["id"].(string); !ids[id] {
|
||||
taken = append(taken, r)
|
||||
}
|
||||
}
|
||||
stub.Resources = taken
|
||||
return stub
|
||||
}
|
||||
|
||||
// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as
|
||||
// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is
|
||||
// not among them; a file among them is a folded path.
|
||||
func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *engcomp.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) {
|
||||
idOf := func(raw json.RawMessage) (string, string, string, string, error) {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
err := json.Unmarshal(raw, &head)
|
||||
return head.ID, head.Module, head.Type, head.Path, err
|
||||
}
|
||||
present := map[string]bool{}
|
||||
for _, raw := range kept {
|
||||
if id, module, _, _, err := idOf(raw); err == nil && module == "" {
|
||||
present[id] = true
|
||||
}
|
||||
}
|
||||
// By the id of the resource of the node's own it runs after, "" for first: the last one before it that is composed.
|
||||
after := map[string][]json.RawMessage{}
|
||||
anchor := ""
|
||||
for _, raw := range runs.Resources {
|
||||
id, module, typ, path, err := idOf(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("compose: a resource the node runs in its own composition: %w", err)
|
||||
}
|
||||
if !carried(module, id) {
|
||||
if module == "" && present[id] {
|
||||
anchor = id
|
||||
}
|
||||
continue
|
||||
}
|
||||
after[anchor] = append(after[anchor], raw)
|
||||
if typ == string(declaration.TypeFile) {
|
||||
foldedPaths[path] = true
|
||||
}
|
||||
}
|
||||
out := make([]json.RawMessage, 0, len(kept)+len(runs.Resources))
|
||||
out = append(out, after[""]...)
|
||||
for _, raw := range kept {
|
||||
out = append(out, raw)
|
||||
if id, module, _, _, err := idOf(raw); err == nil && module == "" {
|
||||
out = append(out, after[id]...)
|
||||
}
|
||||
}
|
||||
return out, foldedPaths, nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
func fpFor(c byte) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||
|
||||
// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs
|
||||
// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on
|
||||
// it; a module given no fingerprint is named as skipped and composed nowhere.
|
||||
func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
in := aSmallMesh(t)
|
||||
d, _, err := Compose(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want []string
|
||||
for _, r := range d.Resources {
|
||||
want = append(want, r["type"].(string)+":"+r["id"].(string))
|
||||
}
|
||||
sort.Strings(want)
|
||||
|
||||
fps := map[string]string{"mesh-wireguard": fpFor('a'), "files": fpFor('b')}
|
||||
out, skipped, err := Compositions(in, "laptop", fps, Foreseeing)
|
||||
if err != nil || len(skipped) != 0 {
|
||||
t.Fatalf("Compositions = %v, skipped %v", err, skipped)
|
||||
}
|
||||
var got []string
|
||||
for key, c := range out {
|
||||
node, module, fp, isNode, err := engcomp.ParseKey(key)
|
||||
if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) {
|
||||
t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err)
|
||||
}
|
||||
if !isNode && fp != fps[module] {
|
||||
t.Errorf("%s is composed at %s, given %s", module, fp, fps[module])
|
||||
}
|
||||
if _, err := engcomp.Encode(c); err != nil {
|
||||
t.Errorf("%s: %v", key, err)
|
||||
}
|
||||
for _, raw := range c.Resources {
|
||||
var r struct{ ID, Type, Module string }
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
if r.Module != module {
|
||||
t.Errorf("%s holds %s of %q", key, r.ID, r.Module)
|
||||
}
|
||||
got = append(got, r.Type+":"+r.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(got)
|
||||
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("the compositions hold %v, the declaration %v", got, want)
|
||||
}
|
||||
|
||||
one, skipped, err := Compositions(in, "laptop", map[string]string{"mesh-wireguard": fpFor('a')}, Foreseeing)
|
||||
if err != nil || strings.Join(skipped, ",") != "files" {
|
||||
t.Fatalf("one module: %v, skipped %v", err, skipped)
|
||||
}
|
||||
for _, c := range one {
|
||||
if c.Module == "files" {
|
||||
t.Errorf("a module given no fingerprint was composed")
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1126: a module the node runs inside its own composition (Node.InOwn), and the private network's always,
|
||||
// given no fingerprint of the new form, is folded into the node's own as the controller's composed writer folds it:
|
||||
// every resource in its place, its module taken off, its id still naming it, nothing skipped.
|
||||
folding := in
|
||||
folding.Nodes = append([]Node(nil), in.Nodes...)
|
||||
for i := range folding.Nodes {
|
||||
if folding.Nodes[i].Name == "laptop" {
|
||||
folding.Nodes[i].InOwn = map[string]bool{"files": true}
|
||||
}
|
||||
}
|
||||
all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing)
|
||||
nodeKey, _ := engcomp.NodeKey("laptop")
|
||||
if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) {
|
||||
t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want))
|
||||
}
|
||||
for i, raw := range all[nodeKey].Resources {
|
||||
var r map[string]any
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
if _, says := r["module"]; says || r["id"] != d.Resources[i]["id"] {
|
||||
t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"])
|
||||
}
|
||||
}
|
||||
if _, err := engcomp.Encode(all[nodeKey]); err != nil {
|
||||
t.Errorf("the node's own with the folded modules: %v", err)
|
||||
}
|
||||
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
|
||||
t.Errorf("a node the mesh does not have was composed")
|
||||
}
|
||||
|
||||
// Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still
|
||||
// contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the
|
||||
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
|
||||
// held module's new build's bundles are not delivered.
|
||||
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
|
||||
runtime := catalogue.Manifest{Module: "node-tools", Version: "1", ToolRunner: true,
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
|
||||
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
|
||||
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
|
||||
store := catalogue.Manifest{Module: "artifacts", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}},
|
||||
Listens: []catalogue.Listening{{Name: "registry", Port: 5100, From: catalogue.FromMesh}}}
|
||||
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
|
||||
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}}
|
||||
newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served := "lamp=" + resolution.BundlePath("lamp", "tools") + "/lamp-tools"
|
||||
runtimeRuns := func(dropped bool) engcomp.Composed {
|
||||
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
|
||||
if dropped {
|
||||
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
|
||||
}
|
||||
return engcomp.Composed{Node: "anchor", Module: "node-tools", Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
|
||||
}
|
||||
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
|
||||
`","digest":"` + dg('b') + `","path":"` + resolution.BundlePath("lamp", "tools") + `"}`
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
set func(n *Node) engcomp.Composed // and what runs of lamp
|
||||
fps map[string]string
|
||||
dropped bool
|
||||
}{
|
||||
{"folded at a build of the old form", func(n *Node) engcomp.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &engcomp.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, false},
|
||||
{"folded, the runtime already dropped it", func(n *Node) engcomp.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &engcomp.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, true},
|
||||
{"held, its new build not delivered", func(n *Node) engcomp.Composed {
|
||||
n.Held = map[string]bool{"lamp": true}
|
||||
lr := engcomp.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
|
||||
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
|
||||
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
|
||||
return lr
|
||||
}, map[string]string{"lamp": fpFor('1')}, false},
|
||||
} {
|
||||
tools := aMesh(t)
|
||||
tools.Catalogue["node-tools"], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
|
||||
anchor := nodeOf(t, &tools, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "artifacts", "node-tools", "lamp")
|
||||
tools.BusMinted = []string{"anchor." + "node-tools"}
|
||||
anchor.SealingKey = keyOf(t, "anchor")
|
||||
anchor.RunsTools = ToolsRunning([]engcomp.Composed{runtimeRuns(row.dropped), row.set(anchor)})
|
||||
row.fps[overlay.Name], row.fps["artifacts"], row.fps["node-tools"] = fpFor('a'), fpFor('5'), fpFor('e')
|
||||
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
var env map[string]any
|
||||
for _, c := range out {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Env map[string]any `json:"env"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" {
|
||||
env = r.Env
|
||||
}
|
||||
}
|
||||
}
|
||||
if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` {
|
||||
t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name,
|
||||
env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
// Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they
|
||||
// differ as the controller's Reading and Foreseeing modes do.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
// Reading composes what is held and leaves out an own secret the Input holds none for: that machine cannot be running it.
|
||||
Reading Mode = iota
|
||||
// Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would
|
||||
// refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words.
|
||||
Foreseeing
|
||||
// Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none
|
||||
// for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one.
|
||||
// The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied.
|
||||
Planning
|
||||
)
|
||||
|
||||
// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send.
|
||||
// Never sent.
|
||||
const (
|
||||
ForeseenCertificate = "foreseen: issued by the next send"
|
||||
ForeseenBusUsers = "foreseen: composed from the bus records by the next send"
|
||||
)
|
||||
|
||||
// grantsFor is every credential this node must create, because something elsewhere uses it (plan.go grantsFor): the mirror of
|
||||
// what a consumer is given. A consumer whose identity overflows the provision's bound is left out of the grants and returned
|
||||
// beside them (novox/hq ADR 0225); one whose resolution binds it to another provider is withdrawn and returned beside them
|
||||
// (issue 274). Every other is granted.
|
||||
func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overflow, []catalogue.Unbound, []string, error) {
|
||||
issued, err := c.rowsFor(node)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
onNetwork, err := c.whereEveryoneIs()
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
out := make([]catalogue.Grant, 0, len(issued))
|
||||
var withheld []catalogue.Overflow
|
||||
var unbound []catalogue.Unbound
|
||||
var foreseen []string
|
||||
for _, s := range issued {
|
||||
resolved, err := c.planFor(s.Consumer)
|
||||
switch {
|
||||
case unresolvable(err):
|
||||
// Their set does not resolve. Skipped rather than fatal: this node is not the place to report another machine's problem.
|
||||
continue
|
||||
case err != nil:
|
||||
// The mesh could not be asked what they wanted, which is not the same as their wanting nothing.
|
||||
return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err)
|
||||
}
|
||||
plan := resolved.Plan
|
||||
values, asks, err := resolution.ContributionsFrom(plan, s.Provision, s.ConsumerModule, resolved.Settings)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
// A port in there is the consumer's software port until this: the assignment that moved it is that machine's.
|
||||
consumer, err := c.in.node(s.Consumer)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
values = resolution.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule])
|
||||
from := s.ConsumerModule
|
||||
if !asks {
|
||||
// That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever.
|
||||
from = ""
|
||||
}
|
||||
if bound := resolution.BindsFrom(plan, s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) {
|
||||
// It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said.
|
||||
from = ""
|
||||
unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node,
|
||||
Consumer: s.Consumer, Module: s.ConsumerModule, Local: s.Local, BoundTo: bound})
|
||||
}
|
||||
// The consumer's identity slug, from its own manifest, carried on the grant so the provider derives the same login the
|
||||
// consumer does (novox/hq ADR 0049).
|
||||
slug := ""
|
||||
for _, mm := range plan.Modules {
|
||||
if mm.Module == s.ConsumerModule {
|
||||
slug = mm.Slug
|
||||
break
|
||||
}
|
||||
}
|
||||
if from != "" {
|
||||
bound := boundOfGrant(plan, s, node)
|
||||
source := catalogue.IdentitySource(slug, s.ConsumerModule)
|
||||
if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil {
|
||||
withheld = append(withheld, catalogue.Overflow{Provision: s.Provision, Provider: node,
|
||||
Consumer: s.Consumer, Module: s.ConsumerModule,
|
||||
Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound})
|
||||
continue
|
||||
}
|
||||
}
|
||||
sealed := s.ForProvider
|
||||
switch {
|
||||
case credentiallessRow(plan, s, node):
|
||||
// Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and
|
||||
// nothing stands in for it, so neither end waits for a value nobody makes (issue 1126).
|
||||
sealed = ""
|
||||
case sealed == "":
|
||||
sealed = catalogue.ForeseenSealed
|
||||
foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer))
|
||||
}
|
||||
out = append(out, catalogue.Grant{
|
||||
Provision: s.Provision, Consumer: s.Consumer, At: onNetwork[s.Consumer],
|
||||
From: from, Values: values, Slug: slug, Sealed: sealed, Local: s.Local})
|
||||
}
|
||||
return out, withheld, unbound, foreseen, nil
|
||||
}
|
||||
|
||||
// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault
|
||||
// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126).
|
||||
func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone }
|
||||
|
||||
// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none.
|
||||
func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool {
|
||||
return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool {
|
||||
return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider &&
|
||||
credentialless(n)
|
||||
})
|
||||
}
|
||||
|
||||
// rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them
|
||||
// (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send
|
||||
// would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not
|
||||
// answered by a record and not a credential only a person gives and nobody gave. Those have no sealed copy (ForProvider empty), so the
|
||||
// grant is composed with a stand-in and named foreseen (plan.go: planFor makes the secret in every mode before grantsFor reads it).
|
||||
func (c *composer) rowsFor(node string) ([]Issued, error) {
|
||||
var rows []Issued
|
||||
for _, row := range c.in.Issued {
|
||||
if row.Provider == node {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
for _, other := range c.in.sorted() {
|
||||
resolved, err := c.planFor(other.Name)
|
||||
switch {
|
||||
case unresolvable(err):
|
||||
continue
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", other.Name, node, err)
|
||||
}
|
||||
shelf := c.shelfFor(other)
|
||||
for _, n := range resolved.Plan.Needs {
|
||||
if n.From != node || n.ByRecord {
|
||||
continue
|
||||
}
|
||||
cred, held := c.credential(n, other.Name)
|
||||
if _, outside := issuedOutsideBy(shelf, n); cred.State == CredentialNotGiven || (outside && !(held && cred.State == CredentialHeld)) {
|
||||
continue
|
||||
}
|
||||
if slices.ContainsFunc(rows, func(r Issued) bool {
|
||||
return r.Provision == n.Name && r.Consumer == other.Name && r.ConsumerModule == n.For && r.Local == n.Local
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, Issued{Provision: n.Name, Consumer: other.Name, ConsumerModule: n.For, Local: n.Local, Provider: node})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
switch {
|
||||
case a.Provision != b.Provision:
|
||||
return a.Provision < b.Provision
|
||||
case a.Consumer != b.Consumer:
|
||||
return a.Consumer < b.Consumer
|
||||
case a.ConsumerModule != b.ConsumerModule:
|
||||
return a.ConsumerModule < b.ConsumerModule
|
||||
}
|
||||
return a.Local < b.Local
|
||||
})
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this grant answers. A requirement
|
||||
// not found there is held to the tightest bound the mesh knows rather than to none.
|
||||
func boundOfGrant(consumer catalogue.Resolution, s Issued, provider string) catalogue.IdentityBound {
|
||||
for _, n := range consumer.Needs {
|
||||
if n.Name == s.Provision && n.For == s.ConsumerModule && n.From == provider {
|
||||
return n.Identity
|
||||
}
|
||||
}
|
||||
return catalogue.DefaultIdentityBound
|
||||
}
|
||||
|
||||
// ownSecretRow is the row for one own secret of a module on a node.
|
||||
func (c *composer) ownSecretRow(node, module, name string) (OwnSecretRow, bool) {
|
||||
for _, r := range c.in.OwnSecrets {
|
||||
if r.Node == node && r.Module == module && r.Name == name {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return OwnSecretRow{}, false
|
||||
}
|
||||
|
||||
// givenMember is one own secret a person gave on a machine and whether it is sealed to the key the machine holds now.
|
||||
type givenMember struct {
|
||||
Name string
|
||||
Sealed string
|
||||
Current bool
|
||||
}
|
||||
|
||||
// givenMembers is every member of a module's secret family given on a machine, by name (inv.GivenMembers): the mesh never
|
||||
// makes a member, so only a value a person gave is one; a machine with no sealing key holds none.
|
||||
func (c *composer) givenMembers(node Node, module, family string) []givenMember {
|
||||
if node.SealingKey == "" {
|
||||
return nil
|
||||
}
|
||||
prefix := catalogue.FamilyPrefix(family)
|
||||
var out []givenMember
|
||||
for _, r := range c.in.OwnSecrets {
|
||||
if r.Node == node.Name && r.Module == module && r.Origin == "accepted" && strings.HasPrefix(r.Name, prefix) {
|
||||
out = append(out, givenMember{Name: r.Name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// givenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (inv.GivenOwnSecret): not given when
|
||||
// the value held was made by the mesh or the machine has no sealing key.
|
||||
func (c *composer) givenOwnSecret(node Node, module, name string) (givenMember, bool) {
|
||||
if node.SealingKey == "" {
|
||||
return givenMember{}, false
|
||||
}
|
||||
r, ok := c.ownSecretRow(node.Name, module, name)
|
||||
if !ok || r.Origin != "accepted" {
|
||||
return givenMember{}, false
|
||||
}
|
||||
return givenMember{Name: name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}, true
|
||||
}
|
||||
|
||||
// moduleSecretIfIssued is the read half of the controller's SecretForModule (inv.ModuleSecretIfIssued): the secret when one is
|
||||
// held and sealed to the key the machine holds now, and otherwise not held. A key it was sealed to that the machine no longer
|
||||
// has is reported as absent rather than as an error.
|
||||
func (c *composer) moduleSecretIfIssued(node Node, module, name string) (sealed string, held bool) {
|
||||
if node.SealingKey == "" {
|
||||
return "", false
|
||||
}
|
||||
r, ok := c.ownSecretRow(node.Name, module, name)
|
||||
if !ok || r.SealedTo != node.SealingKey {
|
||||
return "", false
|
||||
}
|
||||
return r.Sealed, true
|
||||
}
|
||||
|
||||
// wouldMakeSecretForModule says what the send would do about an own secret without doing it: nil when it would make one (or one
|
||||
// is held), and otherwise the very refusal it would meet (inv.WouldMakeSecretForModule).
|
||||
func (c *composer) wouldMakeSecretForModule(node Node, module, name string) error {
|
||||
if node.SealingKey == "" {
|
||||
return fmt.Errorf("%s needs a secret and %s has no sealing key, so nothing can be sealed to it", module, node.Name)
|
||||
}
|
||||
r, ok := c.ownSecretRow(node.Name, module, name)
|
||||
switch {
|
||||
case !ok:
|
||||
return nil
|
||||
case r.SealedTo != node.SealingKey && r.Origin == "accepted":
|
||||
return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
|
||||
"since generated a new sealing key. The mesh cannot make another; issue it again", module, node.Name, name, node.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// givenUnderAnOldKey is the refusal for a value a person gave sealed to a key the machine no longer holds.
|
||||
func givenUnderAnOldKey(module, node, name string) error {
|
||||
return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
|
||||
"since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node)
|
||||
}
|
||||
|
||||
// ownSecretsFor is each module's own secrets as this node composes them (plan.go renderingFor): the needed ones sealed to the
|
||||
// node, and, in the Foreseeing mode, the ones the next send would make named foreseen. A module handed to the provisioner has its
|
||||
// secrets from the vault through the client, so none of them is composed here.
|
||||
func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing catalogue.Rendering, mode Mode) (
|
||||
needed map[string]map[string]string, foreseen map[string]map[string]bool, err error) {
|
||||
needed = map[string]map[string]string{}
|
||||
foreseen = map[string]map[string]bool{}
|
||||
mark := func(module, name string) {
|
||||
if foreseen[module] == nil {
|
||||
foreseen[module] = map[string]bool{}
|
||||
}
|
||||
foreseen[module][name] = true
|
||||
}
|
||||
for _, m := range plan.Modules {
|
||||
if resolution.Handing(handing, m) {
|
||||
continue
|
||||
}
|
||||
// A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not
|
||||
// given is nothing -- the module says it waits for it.
|
||||
for _, family := range m.OwnSecrets.Families() {
|
||||
for _, g := range c.givenMembers(node, m.Module, family) {
|
||||
if _, fam, ok := m.OwnSecrets.Lookup(g.Name); !ok || fam != family {
|
||||
continue // a longer family's member, or a name no longer of this family
|
||||
}
|
||||
if !g.Current {
|
||||
continue
|
||||
}
|
||||
if needed[m.Module] == nil {
|
||||
needed[m.Module] = map[string]string{}
|
||||
}
|
||||
needed[m.Module][g.Name] = g.Sealed
|
||||
if g.Sealed == "" {
|
||||
// Given, and its value is not in the Input: placed with a stand-in, named.
|
||||
needed[m.Module][g.Name] = catalogue.ForeseenSealed
|
||||
mark(m.Module, g.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
names := make([]string, 0)
|
||||
for name := range m.OwnSecrets.Plain() {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
own := m.OwnSecrets.Plain()[name]
|
||||
// An own secret issued outside the mesh is never made (novox/hq issue 401, ADR 0228; ADR 0283 for a family): only a
|
||||
// value a person gave is placed. One not given is nothing, and a value the mesh made for it before is not one either.
|
||||
if own.IssuedBy == catalogue.IssuedOutside {
|
||||
g, given := c.givenOwnSecret(node, m.Module, name)
|
||||
if !given || !g.Current {
|
||||
continue
|
||||
}
|
||||
if g.Sealed == "" {
|
||||
mark(m.Module, name)
|
||||
continue
|
||||
}
|
||||
if needed[m.Module] == nil {
|
||||
needed[m.Module] = map[string]string{}
|
||||
}
|
||||
needed[m.Module][name] = g.Sealed
|
||||
continue
|
||||
}
|
||||
sealed, held := c.moduleSecretIfIssued(node, m.Module, name)
|
||||
if !held && mode == Planning {
|
||||
mark(m.Module, name)
|
||||
continue
|
||||
}
|
||||
if !held && mode == Foreseeing {
|
||||
// Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in,
|
||||
// named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is
|
||||
// foreseen, never refused: the vault mints it from the grants part's wanted list once the assignment is written
|
||||
// on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it
|
||||
// (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused).
|
||||
if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
mark(m.Module, name)
|
||||
continue
|
||||
}
|
||||
if !held {
|
||||
// Never issued, so this machine cannot be running it: left out rather than invented.
|
||||
continue
|
||||
}
|
||||
if sealed == "" {
|
||||
// Held, and its value is not in the Input: composed with a stand-in, named.
|
||||
mark(m.Module, name)
|
||||
continue
|
||||
}
|
||||
if needed[m.Module] == nil {
|
||||
needed[m.Module] = map[string]string{}
|
||||
}
|
||||
needed[m.Module][name] = sealed
|
||||
}
|
||||
}
|
||||
return needed, foreseen, nil
|
||||
}
|
||||
|
||||
// CoreBoxWanted is a core credential whose box the Input does not hold for a machine: the next send asks the vault for it and
|
||||
// keeps the answer. It is returned for the caller to ask and keep; this package asks and keeps nothing.
|
||||
type CoreBoxWanted struct {
|
||||
Node string
|
||||
Module string
|
||||
Name string
|
||||
}
|
||||
|
||||
// coreModulesOf is every module of the plan whose manifest says its own secrets are the core's credentials, whose own secrets this machine's declaration carries, with their
|
||||
// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued
|
||||
// outside the mesh is never the vault's to seal here.
|
||||
func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, m := range modules {
|
||||
if !resolution.CarriesVaultSealed(m) || (handed != nil && handed(m)) {
|
||||
continue
|
||||
}
|
||||
var names []string
|
||||
for name, own := range m.OwnSecrets.Plain() {
|
||||
if own.IssuedBy != catalogue.IssuedOutside {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
sort.Strings(names)
|
||||
out[m.Module] = names
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sealingKeyValid says a machine's sealing key, recorded raw in base64, is a raw X25519 key.
|
||||
func sealingKeyValid(raw string) error {
|
||||
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
|
||||
if err != nil || len(b) != 32 {
|
||||
return errors.New("the machine's sealing key is not a raw X25519 key")
|
||||
}
|
||||
_, err = ecdh.X25519().NewPublicKey(b)
|
||||
return err
|
||||
}
|
||||
|
||||
// coreBoxesFor decides each core credential of a machine in a composition that sends nothing and asks no vault (core_box.go
|
||||
// coreBoxesOf, in the modes that are not a send): the kept box while it is sealed to the key the machine holds now, and otherwise a
|
||||
// stand-in that the next send replaces by asking the vault -- returned in wanted for the caller to ask and keep. A machine with no
|
||||
// sealing key, or one whose key is not a key, leaves its module out, said.
|
||||
func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) (
|
||||
sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) {
|
||||
core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return resolution.Handing(handing, m) })
|
||||
sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{}
|
||||
mods := make([]string, 0, len(core))
|
||||
for m := range core {
|
||||
mods = append(mods, m)
|
||||
}
|
||||
sort.Strings(mods)
|
||||
for _, module := range mods {
|
||||
if node.SealingKey == "" {
|
||||
notPlaced[module] = fmt.Sprintf("%s has no sealing key, so the vault can seal %s's credentials to "+
|
||||
"nothing there (novox/hq ADR 0349 §16)", node.Name, module)
|
||||
continue
|
||||
}
|
||||
if err := sealingKeyValid(node.SealingKey); err != nil {
|
||||
notPlaced[module] = fmt.Sprintf("%s's credentials cannot be asked of the vault: %v", module, err)
|
||||
continue
|
||||
}
|
||||
boxes := map[string]json.RawMessage{}
|
||||
for _, name := range core[module] {
|
||||
var kept *CoreBox
|
||||
for i := range c.in.KeptCoreBoxes {
|
||||
k := &c.in.KeptCoreBoxes[i]
|
||||
if k.Node == node.Name && k.Module == module && k.Name == name {
|
||||
kept = k
|
||||
}
|
||||
}
|
||||
if kept != nil && kept.SealedTo == node.SealingKey && len(kept.Box) > 0 {
|
||||
boxes[name] = kept.Box
|
||||
continue
|
||||
}
|
||||
boxes[name] = catalogue.ForeseenVaultBox
|
||||
wanted = append(wanted, CoreBoxWanted{Node: node.Name, Module: module, Name: name})
|
||||
}
|
||||
sealed[module] = boxes
|
||||
}
|
||||
return sealed, notPlaced, wanted
|
||||
}
|
||||
|
||||
// certificateFor is what the mesh certifies about one machine's internal name (plan.go certificateFor): refused for a machine that
|
||||
// never told the mesh what key it serves with, and for one not on the private network, which has no name to be certified for.
|
||||
// The certificate and the authority are public values the Input may carry; without them stand-ins are composed, and named.
|
||||
func (c *composer) certificateFor(node Node) (certificate, authority string, foreseen bool, err error) {
|
||||
if node.ServingKey == "" {
|
||||
return "", "", false, fmt.Errorf(
|
||||
"%s wants a certificate and has never told the mesh what key it serves with; it "+
|
||||
"joins again to report one", node.Name)
|
||||
}
|
||||
where, err := c.whereEveryoneIs()
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
if where[node.Name] == "" {
|
||||
return "", "", false, fmt.Errorf(
|
||||
"%s wants a certificate and is not on the private network, so it has no name inside "+
|
||||
"the mesh to be certified for", node.Name)
|
||||
}
|
||||
certificate, authority = node.Certificate, c.in.Authority
|
||||
if certificate == "" || authority == "" {
|
||||
return ForeseenCertificate, ForeseenCertificate, true, nil
|
||||
}
|
||||
return certificate, authority, false, nil
|
||||
}
|
||||
|
||||
// holdsTheBus says whether a node's set includes the module that asks for the bus's user list.
|
||||
func holdsTheBus(modules []catalogue.Manifest) bool {
|
||||
for _, m := range modules {
|
||||
if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// CredentialKey names one pair credential by the ends it joins: the provision, the consumer's node and module, the
|
||||
// provider's node, and the local name where a module keeps several (Credential's and Issued's key).
|
||||
type CredentialKey struct {
|
||||
Provision string `json:"provision"`
|
||||
Consumer string `json:"consumer"`
|
||||
ConsumerModule string `json:"consumer_module"`
|
||||
Provider string `json:"provider"`
|
||||
Local string `json:"local,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialsNeeded is every pair credential node's composition reads (novox/hq ADR 0547 §2, ADR 0550 §5, issue 1126):
|
||||
// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's,
|
||||
// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the
|
||||
// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the
|
||||
// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is
|
||||
// in neither list: the vault mints nothing for it (issue 1126).
|
||||
func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) {
|
||||
c := newComposer(in)
|
||||
resolved, err := c.resolution(node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, n := range resolved.Needs {
|
||||
if n.ByRecord || n.From == "" || credentialless(n) {
|
||||
continue
|
||||
}
|
||||
asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For,
|
||||
Provider: n.From, Local: n.Local})
|
||||
}
|
||||
rows, err := c.rowsFor(node)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
// A grant whose offer presents no credential (form none) has none to ask for (issue 1126).
|
||||
if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) {
|
||||
continue
|
||||
}
|
||||
asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer,
|
||||
ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local})
|
||||
}
|
||||
sortKeys(asConsumer)
|
||||
sortKeys(asProvider)
|
||||
return asConsumer, asProvider, nil
|
||||
}
|
||||
|
||||
func sortKeys(ks []CredentialKey) {
|
||||
sort.Slice(ks, func(i, j int) bool {
|
||||
a, b := ks[i], ks[j]
|
||||
switch {
|
||||
case a.Provision != b.Provision:
|
||||
return a.Provision < b.Provision
|
||||
case a.Provider != b.Provider:
|
||||
return a.Provider < b.Provider
|
||||
case a.Consumer != b.Consumer:
|
||||
return a.Consumer < b.Consumer
|
||||
case a.ConsumerModule != b.ConsumerModule:
|
||||
return a.ConsumerModule < b.ConsumerModule
|
||||
}
|
||||
return a.Local < b.Local
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// secretValue is a value the Input carries that must never appear in anything the composition says.
|
||||
const secretValue = "SECRET-VALUE-THAT-MUST-NOT-LEAK"
|
||||
|
||||
func rendered(t *testing.T, in Input, node string, mode Mode) Rendered {
|
||||
t.Helper()
|
||||
got, err := Render(in, node, mode)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// anAssistant is mesh-controller's outside_not_given_test.go anAssistant (cmd/mesh-controller at 6c2a8d62): a module with a secret
|
||||
// the mesh makes and one only an outside party issues.
|
||||
func anAssistant() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "assistant", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{
|
||||
"password": {Path: "/var/lib/mesh/assistant/password", Taken: catalogue.TakenAtStart},
|
||||
"vendor-key": {Path: "/var/lib/mesh/assistant/vendor-key", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside},
|
||||
},
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/mesh/assistant", "mode": "0700"}}}
|
||||
}
|
||||
|
||||
func withAssistant(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := aMesh(t)
|
||||
in.Catalogue["assistant"] = anAssistant()
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "assistant")
|
||||
laptop.SealingKey = keyOf(t, "laptop")
|
||||
return in
|
||||
}
|
||||
|
||||
// Own secrets (plan.go renderingFor, novox/hq issue 275 and 401): one held and sealed to the machine's key is carried; one not held is
|
||||
// foreseen in the Foreseeing mode and left out in the Reading mode, and nothing is made.
|
||||
func TestAnOwnSecretHeldIsCarriedAndOneNotHeldIsForeseenOrLeftOut(t *testing.T) {
|
||||
mk := func() Input {
|
||||
in := withAssistant(t)
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made",
|
||||
SealedTo: nodeOf(t, &in, "laptop").SealingKey, Sealed: secretValue}}
|
||||
return in
|
||||
}
|
||||
in := mk()
|
||||
got := rendered(t, in, "laptop", Foreseeing)
|
||||
if got.Rendering.Needed["assistant"]["password"] != secretValue || len(got.Rendering.Foreseen) != 0 {
|
||||
t.Errorf("a held secret: needed %v foreseen %v", got.Rendering.Needed, got.Rendering.Foreseen)
|
||||
}
|
||||
// Not held: foreseen with a stand-in (Foreseeing), left out (Reading).
|
||||
in.OwnSecrets = nil
|
||||
got = rendered(t, in, "laptop", Foreseeing)
|
||||
if !got.Rendering.Foreseen["assistant"]["password"] || got.Rendering.Needed["assistant"]["password"] != "" ||
|
||||
len(got.Foreseen) != 1 || got.Foreseen[0] != "assistant/password" {
|
||||
t.Errorf("a secret the send makes: foreseen %v / %v needed %v", got.Rendering.Foreseen, got.Foreseen, got.Rendering.Needed)
|
||||
}
|
||||
got = rendered(t, in, "laptop", Reading)
|
||||
if len(got.Rendering.Needed["assistant"]) != 0 || len(got.Foreseen) != 0 || got.Rendering.Foreseen != nil {
|
||||
t.Errorf("Reading composed a secret that was never issued: %v %v", got.Rendering.Needed, got.Foreseen)
|
||||
}
|
||||
// Held but sealed to a key the machine no longer has is not held.
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: "an-old-key", Sealed: secretValue}}
|
||||
if got := rendered(t, in, "laptop", Foreseeing); !got.Rendering.Foreseen["assistant"]["password"] {
|
||||
t.Errorf("a secret sealed to an old key is held: %v", got.Rendering.Needed)
|
||||
}
|
||||
// Held with no value in the Input: a stand-in, named.
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: nodeOf(t, &in, "laptop").SealingKey}}
|
||||
if got := rendered(t, in, "laptop", Reading); !got.Rendering.Foreseen["assistant"]["password"] {
|
||||
t.Errorf("a held secret with no value is not named: %v", got.Rendering.Foreseen)
|
||||
}
|
||||
unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) })
|
||||
|
||||
// 1126 (ADR 0547 §2): a node applying what it composed itself never applies a stand-in: a held secret with no value in the
|
||||
// Input is refused, naming it; held with its value, it composes.
|
||||
fps := map[string]string{"assistant": "src2:" + strings.Repeat("a", 64), overlay.Name: "src2:" + strings.Repeat("b", 64)}
|
||||
if _, _, err := CompositionsApplied(in, "laptop", fps); !errors.Is(err, ErrStoodIn) || !strings.Contains(err.Error(), "assistant/password") {
|
||||
t.Errorf("a stand-in was not refused for a node's own apply: %v", err)
|
||||
}
|
||||
if out, _, err := CompositionsApplied(mk(), "laptop", fps); err != nil || len(out) == 0 {
|
||||
t.Errorf("a held secret with its value is refused for a node's own apply: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228): nothing is composed or
|
||||
// foreseen for it; a value the mesh made for it is not one a person gave; a value given is placed once it is sealed to the key held now.
|
||||
func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) {
|
||||
in := withAssistant(t)
|
||||
key := nodeOf(t, &in, "laptop").SealingKey
|
||||
for _, mode := range []Mode{Reading, Foreseeing} {
|
||||
got := rendered(t, in, "laptop", mode)
|
||||
if _, there := got.Rendering.Needed["assistant"]["vendor-key"]; there || got.Rendering.Foreseen["assistant"]["vendor-key"] {
|
||||
t.Fatalf("%v: a secret nobody gave is composed: %v %v", mode, got.Rendering.Needed, got.Rendering.Foreseen)
|
||||
}
|
||||
}
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "made", SealedTo: key, Sealed: secretValue}}
|
||||
if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there {
|
||||
t.Error("a value the mesh made for a secret issued outside was placed")
|
||||
}
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "accepted", SealedTo: key, Sealed: "the vendor's key, sealed"}}
|
||||
if got := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; got != "the vendor's key, sealed" {
|
||||
t.Errorf("a value given is placed as %q", got)
|
||||
}
|
||||
in.OwnSecrets[0].SealedTo = "an-old-key"
|
||||
if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there {
|
||||
t.Error("a value given under an old key was placed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the
|
||||
// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault
|
||||
// mints it once the assignment is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused
|
||||
// "has no bus credential: nothing was issued for ace.mesh-self-check").
|
||||
func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) {
|
||||
broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check",
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/mesh-self-check/broker", Taken: catalogue.TakenAtStart}}}
|
||||
in := withAssistant(t)
|
||||
in.Catalogue["mesh-self-check"] = broker
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "mesh-self-check")
|
||||
for _, minted := range [][]string{nil, {"laptop.mesh-self-check"}} {
|
||||
in.BusMinted = minted
|
||||
got, err := Render(in, "laptop", Foreseeing)
|
||||
if err != nil || !got.Rendering.Foreseen["mesh-self-check"]["broker"] {
|
||||
t.Fatalf("minted %v: the bus account is not foreseen: %v", minted, err)
|
||||
}
|
||||
// Reading refuses nothing for it: the machine is not running it.
|
||||
if _, err := Render(in, "laptop", Reading); err != nil {
|
||||
t.Fatalf("Reading refused a secret the machine is not running: %v", err)
|
||||
}
|
||||
}
|
||||
// A given secret under an old key.
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "accepted", SealedTo: "an-old-key", Sealed: secretValue},
|
||||
{Node: "laptop", Module: "mesh-self-check", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
|
||||
_, err := Render(in, "laptop", Foreseeing)
|
||||
wantOld := "assistant on laptop holds \"password\", which was given to the mesh rather than made by it, and laptop has since generated a new " +
|
||||
"sealing key. The mesh cannot make another; issue it again"
|
||||
if err == nil || err.Error() != wantOld {
|
||||
t.Fatalf("got %v, want %q", err, wantOld)
|
||||
}
|
||||
if strings.Contains(err.Error(), secretValue) {
|
||||
t.Error("a secret value is in an error")
|
||||
}
|
||||
// A machine with no sealing key cannot be sealed to.
|
||||
laptop.SealingKey = ""
|
||||
in.OwnSecrets = nil
|
||||
if _, err := Render(in, "laptop", Foreseeing); err == nil || !strings.Contains(err.Error(), "laptop has no sealing key, so nothing can be sealed to it") {
|
||||
t.Fatalf("no sealing key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// aGrantMesh is the objects provider on anchor and its consumer files on laptop, with the pair credential made.
|
||||
func aGrantMesh(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := theObjectsMesh(t, objects())
|
||||
in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}}
|
||||
return in
|
||||
}
|
||||
|
||||
// A credential made for a consumer is a grant on its provider's machine, carrying the consumer's values and the sealed copy; without
|
||||
// the sealed copy a stand-in is composed and named (novox/hq plan.go grantsFor).
|
||||
func TestAGrantIsCreatedForEachCredentialMadeForAConsumer(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
// 1126 (ADR 0550 §5): the credentials each node's composition reads, by their ends: laptop's files consumes anchor's
|
||||
// s3-bucket, and anchor grants it; the node composing its own asks the vault for exactly these.
|
||||
pair := CredentialKey{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor"}
|
||||
if consumer, provider, err := CredentialsNeeded(in, "laptop"); err != nil || len(provider) != 0 || len(consumer) != 1 || consumer[0] != pair {
|
||||
t.Errorf("laptop needs %v as consumer and %v as provider (%v), want %v as consumer", consumer, provider, err, pair)
|
||||
}
|
||||
if consumer, provider, err := CredentialsNeeded(in, "anchor"); err != nil || len(consumer) != 0 || len(provider) != 1 || provider[0] != pair {
|
||||
t.Errorf("anchor needs %v as consumer and %v as provider (%v), want %v as provider", consumer, provider, err, pair)
|
||||
}
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 1 {
|
||||
t.Fatalf("grants are %+v", got.Rendering.Grants)
|
||||
}
|
||||
g := got.Rendering.Grants[0]
|
||||
if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.At != "laptop.internal" || g.Sealed != "sealed-to-anchor" {
|
||||
t.Errorf("the grant is %+v", g)
|
||||
}
|
||||
if len(got.Foreseen) != 0 || len(got.Withheld) != 0 || len(got.Unbound) != 0 {
|
||||
t.Errorf("foreseen %v withheld %v unbound %v", got.Foreseen, got.Withheld, got.Unbound)
|
||||
}
|
||||
in.Issued[0].ForProvider = ""
|
||||
got = rendered(t, in, "anchor", Foreseeing)
|
||||
if got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed || len(got.Foreseen) != 1 {
|
||||
t.Errorf("a grant with no sealed copy: %+v foreseen %v", got.Rendering.Grants, got.Foreseen)
|
||||
}
|
||||
// A credential made for a consumer on another provider is not this node's grant.
|
||||
if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
|
||||
t.Error("the consumer is granted its own credential")
|
||||
}
|
||||
// A consumer whose set does not resolve is skipped, not fatal.
|
||||
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files")
|
||||
in.Catalogue["files"] = catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
|
||||
Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}}
|
||||
in.Catalogue["twin"] = catalogue.Manifest{Module: "twin", Version: "1", Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}}
|
||||
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "twin")
|
||||
if got, err := Render(in, "anchor", Foreseeing); err != nil || len(got.Rendering.Grants) != 0 {
|
||||
t.Errorf("a consumer that does not resolve: %v %+v", err, got.Rendering.Grants)
|
||||
}
|
||||
}
|
||||
|
||||
// 1364/AC2: a consumer the grants leave out because its identity overflows the provision's bound is returned beside them, naming the
|
||||
// consumer and the provision (novox/hq ADR 0225), and is not granted.
|
||||
func TestAConsumerWhoseIdentityOverflowsIsLeftOutOfTheGrantsAndNamed(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Nodes[1].Name = "a-machine-with-a-very-long-name"
|
||||
in.Issued[0].Consumer = "a-machine-with-a-very-long-name"
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 0 || len(got.Withheld) != 1 {
|
||||
t.Fatalf("grants %+v withheld %+v", got.Rendering.Grants, got.Withheld)
|
||||
}
|
||||
w := got.Withheld[0]
|
||||
if w.Consumer != "a-machine-with-a-very-long-name" || w.Provision != "s3-bucket" || w.Provider != "anchor" || w.Module != "files" ||
|
||||
!strings.Contains(w.String(), "a-machine-with-a-very-long-name") || !strings.Contains(w.String(), "s3-bucket") {
|
||||
t.Errorf("the consumer left out is %+v / %s", w, w.String())
|
||||
}
|
||||
if len(got.Rendering.Withheld) != 1 {
|
||||
t.Errorf("the Rendering does not carry it: %+v", got.Rendering.Withheld)
|
||||
}
|
||||
}
|
||||
|
||||
// 1364/AC3: a credential on record for a consumer that its own resolution binds to another provider is withdrawn, named with where it is
|
||||
// bound (novox/hq issue 274): the grant stays, with no consumer module asking.
|
||||
func TestACredentialBoundToAnotherProviderIsWithdrawnAndSaidWhereItIsBound(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
second := objects()
|
||||
second.Module = "objects2"
|
||||
in.Catalogue["objects2"] = second
|
||||
in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"},
|
||||
Assigned: []string{overlay.Name, "objects2"}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}})
|
||||
nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "objects2"}}
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Unbound) != 1 {
|
||||
t.Fatalf("unbound %+v grants %+v", got.Unbound, got.Rendering.Grants)
|
||||
}
|
||||
u := got.Unbound[0]
|
||||
if u.Provision != "s3-bucket" || u.Provider != "anchor" || u.Consumer != "laptop" || u.Module != "files" ||
|
||||
len(u.BoundTo) != 1 || u.BoundTo[0] != "ace" || !strings.Contains(u.String(), "ace") {
|
||||
t.Errorf("unbound is %+v / %s", u, u.String())
|
||||
}
|
||||
if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].From != "" {
|
||||
t.Errorf("the withdrawn credential is still asked for: %+v", got.Rendering.Grants)
|
||||
}
|
||||
}
|
||||
|
||||
func aCertificateMesh(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := aMesh(t)
|
||||
in.Catalogue["secure"] = catalogue.Manifest{Module: "secure", Version: "1", Certificate: &catalogue.Certificate{Into: "/etc/secure/tls.pem", Authority: "/etc/secure/ca.pem"}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "secure")
|
||||
return in
|
||||
}
|
||||
|
||||
// 1364/AC4: with no certificate and authority in the Input a stand-in is composed and named; given them, the composition carries them as
|
||||
// given. A machine that never reported a serving key, and one not on the private network, are refused in the controller's words.
|
||||
func TestACertificateIsCarriedAsGivenOrStoodInForAndNamed(t *testing.T) {
|
||||
in := aCertificateMesh(t)
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and has never told the mesh what key it serves with; it joins again to report one" {
|
||||
t.Fatalf("no serving key: %v", err)
|
||||
}
|
||||
laptop.ServingKey = "serving-key"
|
||||
got := rendered(t, in, "laptop", Foreseeing)
|
||||
if got.Rendering.Certificate != ForeseenCertificate || got.Rendering.Authority != ForeseenCertificate || len(got.Foreseen) != 1 {
|
||||
t.Errorf("no certificate in the Input: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen)
|
||||
}
|
||||
laptop.Certificate, in.Authority = "the-certificate", "the-authority"
|
||||
got = rendered(t, in, "laptop", Foreseeing)
|
||||
if got.Rendering.Certificate != "the-certificate" || got.Rendering.Authority != "the-authority" || len(got.Foreseen) != 0 {
|
||||
t.Errorf("a certificate given: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen)
|
||||
}
|
||||
laptop.Place.Address = "" // placed nowhere: no name inside the mesh
|
||||
if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and is not on the private network, so it has no name inside the mesh to be certified for" {
|
||||
t.Fatalf("off the network: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func aValidKey(t *testing.T, name string) string { return keyOf(t, name) }
|
||||
|
||||
// 1364/AC5 and AC6: a core credential the node needs and the Input holds no box for is composed with a stand-in and returned as one
|
||||
// to keep; a kept box sealed to the key held now is carried; a node-engine below the vault-box contract is sent none (and nothing is
|
||||
// asked of it); a machine with no sealing key has its core module left out, said.
|
||||
func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) {
|
||||
mk := func() Input {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", CoreCredentials: true,
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "nats")
|
||||
laptop.SealingKey = aValidKey(t, "laptop")
|
||||
laptop.Stated, laptop.Contract = true, VaultBoxContract
|
||||
in.BusMinted = []string{"laptop.nats"}
|
||||
return in
|
||||
}
|
||||
in := mk()
|
||||
got := rendered(t, in, "laptop", Foreseeing)
|
||||
box := got.Rendering.VaultSealed["nats"]["broker"]
|
||||
if string(box) != string(catalogue.ForeseenVaultBox) || len(got.CoreBoxesWanted) != 1 ||
|
||||
got.CoreBoxesWanted[0] != (CoreBoxWanted{Node: "laptop", Module: "nats", Name: "broker"}) {
|
||||
t.Errorf("no box held: %s wanted %v", box, got.CoreBoxesWanted)
|
||||
}
|
||||
kept := json.RawMessage(`{"v":1,"eph":"e","iv":"i","tag":"t","ct":"c"}`)
|
||||
in.KeptCoreBoxes = []CoreBox{{Node: "laptop", Module: "nats", Name: "broker", Generation: 3, SealedTo: nodeOf(t, &in, "laptop").SealingKey, Box: kept}}
|
||||
got = rendered(t, in, "laptop", Foreseeing)
|
||||
if string(got.Rendering.VaultSealed["nats"]["broker"]) != string(kept) || len(got.CoreBoxesWanted) != 0 {
|
||||
t.Errorf("a kept box: %s wanted %v", got.Rendering.VaultSealed["nats"]["broker"], got.CoreBoxesWanted)
|
||||
}
|
||||
in.KeptCoreBoxes[0].SealedTo = "an-old-key"
|
||||
if got = rendered(t, in, "laptop", Foreseeing); len(got.CoreBoxesWanted) != 1 {
|
||||
t.Errorf("a box sealed to an old key is kept: %v", got.CoreBoxesWanted)
|
||||
}
|
||||
// Below the vault-box contract: none, and the controller's own seal as before.
|
||||
nodeOf(t, &in, "laptop").Contract = VaultBoxContract - 1
|
||||
if got = rendered(t, in, "laptop", Foreseeing); got.Rendering.VaultSealed != nil || len(got.CoreBoxesWanted) != 0 {
|
||||
t.Errorf("an engine below the contract is sent boxes: %v %v", got.Rendering.VaultSealed, got.CoreBoxesWanted)
|
||||
}
|
||||
// No sealing key: the module is left out, said.
|
||||
nodeOf(t, &in, "laptop").Contract, nodeOf(t, &in, "laptop").SealingKey = VaultBoxContract, ""
|
||||
got = rendered(t, in, "laptop", Reading)
|
||||
if why := got.Rendering.NotPlaced["nats"]; !strings.Contains(why, "laptop has no sealing key, so the vault can seal nats's credentials to nothing there") {
|
||||
t.Errorf("not placed: %v", got.Rendering.NotPlaced)
|
||||
}
|
||||
unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) })
|
||||
}
|
||||
|
||||
// 1364/AC1: the bus's user list is carried for the machine that runs the bus and for no other; without it a stand-in is composed and
|
||||
// named; the users left out for want of a credential are said; each machine's membership is carried. (The list is composed by the
|
||||
// controller from the bus records, which stay the controller's: see the commit message.)
|
||||
func TestTheBusUserListIsCarriedForTheMachineThatRunsTheBus(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["bus"] = catalogue.Manifest{Module: "bus", Version: "1", BusUsers: "/etc/bus/accounts.conf",
|
||||
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "bus")
|
||||
in.BusUsers = "accounts { anchor.bus: hash }"
|
||||
in.BusMissing = []string{"laptop.files", "laptop.web"}
|
||||
in.BusMemberships = map[string]string{"laptop": "sealed-membership"}
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if got.Rendering.BusUsers != in.BusUsers || len(got.Foreseen) != 0 {
|
||||
t.Errorf("the list is %q foreseen %v", got.Rendering.BusUsers, got.Foreseen)
|
||||
}
|
||||
if len(got.Said) != 1 || got.Said[0] != "the bus's user list leaves out 2 user(s) the mesh has minted no credential for: laptop.files, laptop.web. Each is a user that cannot connect until one is issued" {
|
||||
t.Errorf("said %v", got.Said)
|
||||
}
|
||||
if other := rendered(t, in, "laptop", Foreseeing); other.Rendering.BusUsers != "" || other.Rendering.BusMembership != "sealed-membership" {
|
||||
t.Errorf("laptop: list %q membership %q", other.Rendering.BusUsers, other.Rendering.BusMembership)
|
||||
}
|
||||
in.BusUsers = ""
|
||||
got = rendered(t, in, "anchor", Foreseeing)
|
||||
if got.Rendering.BusUsers != ForeseenBusUsers || len(got.Foreseen) != 1 {
|
||||
t.Errorf("no list in the Input: %q foreseen %v", got.Rendering.BusUsers, got.Foreseen)
|
||||
}
|
||||
}
|
||||
|
||||
// 1364/AC7: nothing the composition says carries a secret value the Input holds.
|
||||
func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued[0].ForProvider = secretValue
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
in.Catalogue["assistant"] = anAssistant()
|
||||
laptop.Assigned = append(laptop.Assigned, "assistant")
|
||||
laptop.SealingKey = keyOf(t, "laptop")
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
|
||||
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld, Sealed: secretValue}}
|
||||
for _, node := range []string{"anchor", "laptop"} {
|
||||
got := rendered(t, in, node, Foreseeing)
|
||||
for _, said := range append(append([]string{}, got.Foreseen...), got.Said...) {
|
||||
if strings.Contains(said, secretValue) {
|
||||
t.Errorf("%s: %q carries a secret value", node, said)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential
|
||||
// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for
|
||||
// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no
|
||||
// credential, nothing stands in on either side, and neither end asks the vault for one.
|
||||
func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
form string
|
||||
cred *catalogue.OfferCredential
|
||||
}{
|
||||
{"undeclared", nil},
|
||||
{catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}},
|
||||
{catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}},
|
||||
{catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}},
|
||||
} {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
provider := in.Catalogue["objects"]
|
||||
provider.Provides[0].Credential = tc.cred
|
||||
if tc.cred != nil && tc.cred.Own != "" {
|
||||
provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}}
|
||||
}
|
||||
in.Catalogue["objects"] = provider
|
||||
none := tc.form == catalogue.CredentialFormNone
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 1 {
|
||||
t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants)
|
||||
}
|
||||
g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed
|
||||
if none {
|
||||
wantSealed = ""
|
||||
}
|
||||
if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed {
|
||||
t.Errorf("%s: the grant is %+v", tc.form, g)
|
||||
}
|
||||
if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none {
|
||||
t.Errorf("%s: foreseen %v", tc.form, got.Foreseen)
|
||||
}
|
||||
consumer, asProvider, err := CredentialsNeeded(in, "laptop")
|
||||
_, anchorProvides, err2 := CredentialsNeeded(in, "anchor")
|
||||
if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none {
|
||||
t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2)
|
||||
}
|
||||
resolved, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.form, err)
|
||||
}
|
||||
if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none {
|
||||
t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen)
|
||||
}
|
||||
if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 {
|
||||
t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen)
|
||||
}
|
||||
// Only the provider's own: the consumer is granted nothing, and nothing in the Input changed.
|
||||
if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
|
||||
t.Errorf("%s: the consumer is granted its own credential", tc.form)
|
||||
}
|
||||
if len(in.Issued) != 0 {
|
||||
t.Errorf("%s: a row was added to the Input", tc.form)
|
||||
}
|
||||
// A row made long ago for an offer of form none (the controller's) carries no credential either.
|
||||
in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}}
|
||||
if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") {
|
||||
t.Errorf("%s: an old row's grant is %+v", tc.form, g)
|
||||
}
|
||||
}
|
||||
// A credential only a person gives and nobody has is no grant.
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}}
|
||||
if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 {
|
||||
t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants)
|
||||
}
|
||||
}
|
||||
|
||||
// And a consumer whose identity overflows the provision's bound is judged before any credential is made for it (issue 263).
|
||||
func TestAnOverflowingConsumerNoCredentialWasMadeForYetIsWithheld(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
in.Catalogue["resolver"] = storeManifests()[1]
|
||||
in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}}
|
||||
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "networkmanager")
|
||||
nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "resolver")
|
||||
in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}}
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Withheld) != 1 || got.Withheld[0].Module != "networkmanager" {
|
||||
t.Fatalf("withheld %+v", got.Withheld)
|
||||
}
|
||||
}
|
||||
|
||||
// The grants come in the order the controller reads them, whatever order the Input gives its rows.
|
||||
func TestGrantsComeInTheOrderOfProvisionConsumerModuleAndLocal(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = []Issued{
|
||||
{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "b", Provider: "anchor", ForProvider: "x"},
|
||||
{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "a", Provider: "anchor", ForProvider: "x"},
|
||||
}
|
||||
rows, err := newComposer(in).rowsFor("anchor")
|
||||
// The credential the same send would make (no local name) comes first, then the two the Input gives, a before b.
|
||||
if err != nil || len(rows) != 3 || rows[0].Local != "" || rows[1].Local != "a" || rows[2].Local != "b" {
|
||||
t.Errorf("rows are %+v %v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Round 1, finding 4: a member of a secret family a person gave, whose sealed value the Input does not carry, is placed with a
|
||||
// stand-in and named foreseen, not dropped as if nobody gave it.
|
||||
func TestAGivenFamilyMemberWithoutItsValueIsAStandInAndNamedForeseen(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["mailer"] = catalogue.Manifest{Module: "mailer", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{"credential-*": {Path: "/var/lib/mailer/credential-*.secret", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "mailer")
|
||||
laptop.SealingKey = keyOf(t, "laptop")
|
||||
in.OwnSecrets = []OwnSecretRow{
|
||||
{Node: "laptop", Module: "mailer", Name: "credential-a", Origin: "accepted", SealedTo: laptop.SealingKey},
|
||||
{Node: "laptop", Module: "mailer", Name: "credential-b", Origin: "accepted", SealedTo: laptop.SealingKey, Sealed: "b-sealed"},
|
||||
}
|
||||
got := rendered(t, in, "laptop", Foreseeing)
|
||||
needed := got.Rendering.Needed["mailer"]
|
||||
if needed["credential-a"] != catalogue.ForeseenSealed || needed["credential-b"] != "b-sealed" {
|
||||
t.Errorf("needed %v", needed)
|
||||
}
|
||||
if !has(got.Foreseen, "mailer/credential-a") || has(got.Foreseen, "mailer/credential-b") {
|
||||
t.Errorf("foreseen %v", got.Foreseen)
|
||||
}
|
||||
}
|
||||
|
||||
// Round 1, finding 5: the suffix internal names end in is a field of the Input, and the environment is never read.
|
||||
func TestTheInternalSuffixIsTheInputsAndNeverTheEnvironments(t *testing.T) {
|
||||
t.Setenv(overlay.SuffixVar, "from-the-environment")
|
||||
in := aGrantMesh(t)
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if got.Rendering.Suffix != "internal" || got.Rendering.Grants[0].At != "laptop.internal" {
|
||||
t.Errorf("by default: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At)
|
||||
}
|
||||
in.InternalSuffix = ".lan"
|
||||
got = rendered(t, in, "anchor", Foreseeing)
|
||||
if got.Rendering.Suffix != "lan" || got.Rendering.Grants[0].At != "laptop.lan" {
|
||||
t.Errorf("given: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At)
|
||||
}
|
||||
where, err := newComposer(in).whereEveryoneIs()
|
||||
if err != nil || where["anchor"] != "anchor.lan" {
|
||||
t.Errorf("where everyone is: %v %v", where, err)
|
||||
}
|
||||
if names := newComposer(in).namesInTheMesh(); names["anchor.lan"] == "" || names["anchor.internal"] != "" {
|
||||
t.Errorf("names: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Round 2: a need answered on the same machine is a grant too (catalogue resolve.go gives it From: the node's own name).
|
||||
func TestAConsumerOnTheProvidersOwnNodeIsGrantedWithAStandInAndNamedForeseen(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor")
|
||||
laptop.Assigned = []string{overlay.Name}
|
||||
anchor.Assigned = append(anchor.Assigned, "files")
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].Consumer != "anchor" || got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed {
|
||||
t.Fatalf("grants are %+v", got.Rendering.Grants)
|
||||
}
|
||||
if !has(got.Foreseen, "the grant of s3-bucket for files on anchor") {
|
||||
t.Errorf("foreseen %v", got.Foreseen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it.
|
||||
func aPublicKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
// keys are made once per run, so two Inputs made the same way are equal.
|
||||
var keys = map[string]string{}
|
||||
|
||||
func keyOf(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
if keys[name] == "" {
|
||||
keys[name] = aPublicKey(t)
|
||||
}
|
||||
return keys[name]
|
||||
}
|
||||
|
||||
// theOverlayModule is the module the mesh provides for itself, as mesh-controller's providedModules makes it
|
||||
// (cmd/mesh-controller/modules.go at 6c2a8d62).
|
||||
func theOverlayModule(t *testing.T) catalogue.Manifest {
|
||||
t.Helper()
|
||||
var m catalogue.Manifest
|
||||
b, err := json.Marshal(overlay.Manifest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// aMesh is the Input of mesh-controller's aMesh (cmd/mesh-controller/mesh_for_test.go at 6c2a8d62): two placed
|
||||
// machines, `anchor` the hub, both reporting container-runtime, wireguard and systemd, both assigned what puts them on the
|
||||
// private network, each at <name>.example:51820 on the site `here`, anchor at 10.77.0.1 and laptop at 10.77.0.2.
|
||||
func aMesh(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: theOverlayModule(t)}}
|
||||
for i, name := range []string{"anchor", "laptop"} {
|
||||
in.Nodes = append(in.Nodes, Node{
|
||||
Name: name,
|
||||
Place: Place{Key: keyOf(t, name), Endpoint: name + ".example:51820", Site: "here",
|
||||
Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]},
|
||||
Assigned: []string{overlay.Name},
|
||||
Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true},
|
||||
})
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// nodeOf is the named node of an Input, for a test to change.
|
||||
func nodeOf(t *testing.T, in *Input, name string) *Node {
|
||||
t.Helper()
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name == name {
|
||||
return &in.Nodes[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("no node %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// unchanged runs call on one Input and compares it with another made the same way: the composition changes nothing in what
|
||||
// it is given (deep equality, nothing shared).
|
||||
func unchanged(t *testing.T, mk func() Input, call func(Input)) {
|
||||
t.Helper()
|
||||
used, same := mk(), mk()
|
||||
call(used)
|
||||
if !reflect.DeepEqual(used, same) {
|
||||
t.Fatalf("the call changed its Input")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"github.com/novox/mesh-host/internal/compositions"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
// Answer is a node's answer to its proposal's assignment candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
|
||||
// s.Assignments laid over the trunk's assignment (compose.CompositionsShadow, its own sealed values and boxes through r as
|
||||
// for its own composition), compared with what it runs (compositions.CompareComposed). It applies nothing and writes
|
||||
// nothing; the proposal and the revision are the candidate's, said back in the answer.
|
||||
//
|
||||
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
|
||||
// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the
|
||||
// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own
|
||||
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
|
||||
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
|
||||
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []compositions.Composed, proposal string, revision uint64) nodeengine.CandidateAnswer {
|
||||
a := nodeengine.CandidateAnswer{Proposal: proposal, Revision: int64(revision)}
|
||||
refuse := func(err error) nodeengine.CandidateAnswer {
|
||||
a.Refused = err.Error()
|
||||
a.Verdict = nodeengine.CandidateAnswerVerdictRefused
|
||||
return a
|
||||
}
|
||||
trunk, err := r.Assignments(ctx)
|
||||
if err != nil {
|
||||
return refuse(fmt.Errorf("frombus: assignment: %w", err))
|
||||
}
|
||||
if s.Running == nil {
|
||||
s.Running = running
|
||||
}
|
||||
// Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and
|
||||
// below, a module the composition refuses, each composed as a stub so the rest is composed and compared.
|
||||
failed := map[string]string{}
|
||||
in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil)
|
||||
if err != nil {
|
||||
return refuse(err)
|
||||
}
|
||||
asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
|
||||
if err != nil {
|
||||
return refuse(err)
|
||||
}
|
||||
// What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the
|
||||
// whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is
|
||||
// assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet
|
||||
// issued under Added (review of L2b, round 1, finding 1).
|
||||
runs := map[string]bool{}
|
||||
for _, c := range running {
|
||||
if c.Module != "" {
|
||||
runs[c.Module] = true
|
||||
}
|
||||
}
|
||||
var consumed []compose.CredentialKey
|
||||
for _, k := range asConsumer {
|
||||
if runs[k.ConsumerModule] {
|
||||
consumed = append(consumed, k)
|
||||
}
|
||||
}
|
||||
asConsumer = consumed
|
||||
if len(asConsumer)+len(asProvider) > 0 {
|
||||
if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
|
||||
return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err))
|
||||
}
|
||||
}
|
||||
composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps))
|
||||
if err != nil {
|
||||
// Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's.
|
||||
if len(failed) == 0 {
|
||||
return refuse(err)
|
||||
}
|
||||
failed[nodeengineclient.NodeWord] = err.Error()
|
||||
composed, skipped, stood = nil, nil, nil
|
||||
}
|
||||
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
|
||||
// names it (the node agent's own composer hands it on that want alone), so a candidate does not add it.
|
||||
runsOwn := false
|
||||
for _, c := range running {
|
||||
runsOwn = runsOwn || c.Module == ""
|
||||
}
|
||||
if !runsOwn {
|
||||
for key, c := range composed {
|
||||
if c.Module == "" {
|
||||
delete(composed, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, whole := failed[nodeengineclient.NodeWord]; whole {
|
||||
// Nothing composed to compare: every refusal is said, and nothing else.
|
||||
for _, m := range sortedKeys(failed) {
|
||||
a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]})
|
||||
}
|
||||
heldAsToday(&a, s.HeldToday)
|
||||
a.Verdict = compositions.VerdictOf(a)
|
||||
return a
|
||||
}
|
||||
// A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from
|
||||
// the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531).
|
||||
if len(s.InOwn) > 0 {
|
||||
var judged []compositions.Composed
|
||||
for _, c := range running {
|
||||
if c.Module == "" || !slices.Contains(s.InOwn, c.Module) {
|
||||
judged = append(judged, c)
|
||||
}
|
||||
}
|
||||
running = judged
|
||||
}
|
||||
cmp := compositions.CompareComposedWith(composed, running, s.SameValue)
|
||||
// The answer rides in the node's have key: its shown bodies are bounded, the rest named only.
|
||||
budget := MaxShown
|
||||
for i := range cmp.Differs {
|
||||
kept := cmp.Differs[i].Bodies[:0]
|
||||
for _, b := range cmp.Differs[i].Bodies {
|
||||
if n := len(b.Composed) + len(b.Running); n <= budget {
|
||||
budget -= n
|
||||
kept = append(kept, b)
|
||||
} else {
|
||||
kept = append(kept, nodeengine.CandidateBodies{ID: b.ID, Why: b.Why})
|
||||
a.Note = "some differing resources are named without their bodies: the answer holds at most " +
|
||||
fmt.Sprint(MaxShown) + " bytes of them"
|
||||
}
|
||||
}
|
||||
cmp.Differs[i].Bodies = kept
|
||||
}
|
||||
// A refused module was composed as a stub: what it composed is no difference; its refusal is said.
|
||||
notFailed := func(m string) bool { _, ok := failed[m]; return !ok }
|
||||
a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) })
|
||||
a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) })
|
||||
a.Differs = slices.DeleteFunc(cmp.Differs, func(d nodeengine.CandidateDiffers) bool { return !notFailed(d.Module) })
|
||||
cmp.Added = slices.DeleteFunc(cmp.Added, func(d nodeengine.CandidateAdded) bool { return !notFailed(d.Module) })
|
||||
skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
|
||||
|
||||
// What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node.
|
||||
added := map[string]bool{}
|
||||
for node, p := range s.Assignments {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
was := map[string]bool{}
|
||||
for _, m := range trunk[node].Modules {
|
||||
was[m.Module] = true
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
if !was[m.Module] {
|
||||
added[node+"/"+m.Module] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
unissued := map[string][]string{}
|
||||
refusals := map[string][]string{}
|
||||
for _, x := range stood {
|
||||
node, module := standsInFor(x, s.Node)
|
||||
switch {
|
||||
case node == s.Node && runs[module]:
|
||||
// A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is
|
||||
// empty, and every module counts as the group's).
|
||||
refusals[module] = append(refusals[module], x)
|
||||
case module != "" && added[node+"/"+module]:
|
||||
if node == s.Node {
|
||||
unissued[module] = append(unissued[module], x)
|
||||
} // another node's addition: the module here that carries it differs, which the assigner judges
|
||||
case module == "" || node != s.Node:
|
||||
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
|
||||
refusals[nodeengineclient.NodeWord] = append(refusals[nodeengineclient.NodeWord], x)
|
||||
default:
|
||||
refusals[module] = append(refusals[module], x)
|
||||
}
|
||||
}
|
||||
addedHere := map[string]nodeengine.CandidateAdded{}
|
||||
for _, m := range cmp.Added {
|
||||
addedHere[m.Module] = m
|
||||
}
|
||||
for _, m := range skipped { // placed, and no build given to compose it at
|
||||
addedHere[m] = nodeengine.CandidateAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
|
||||
}
|
||||
for m, xs := range unissued {
|
||||
got := addedHere[m]
|
||||
got.Module = m
|
||||
got.Unissued = append(got.Unissued, xs...)
|
||||
addedHere[m] = got
|
||||
}
|
||||
for _, m := range sortedKeys(addedHere) {
|
||||
a.Added = append(a.Added, addedHere[m])
|
||||
}
|
||||
for _, m := range sortedKeys(refusals) {
|
||||
if _, isAdded := addedHere[m]; isAdded {
|
||||
continue // a module the candidate adds here: what it lacks is said under Added
|
||||
}
|
||||
if _, isFailed := failed[m]; isFailed {
|
||||
continue // said below, as why it could not be composed
|
||||
}
|
||||
a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
|
||||
}
|
||||
for _, m := range sortedKeys(failed) {
|
||||
a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]})
|
||||
}
|
||||
sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module })
|
||||
heldAsToday(&a, s.HeldToday)
|
||||
a.Verdict = compositions.VerdictOf(a)
|
||||
return a
|
||||
}
|
||||
|
||||
// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how:
|
||||
// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue
|
||||
// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No
|
||||
// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered.
|
||||
func heldAsToday(a *nodeengine.CandidateAnswer, today map[string]string) {
|
||||
if len(today) == 0 {
|
||||
return
|
||||
}
|
||||
kept := a.Refusals[:0]
|
||||
for _, r := range a.Refusals {
|
||||
if w, keep := today[r.Module]; keep {
|
||||
r.Today = w
|
||||
a.Held = append(a.Held, r)
|
||||
} else {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
a.Refusals = kept
|
||||
if len(a.Refusals) == 0 {
|
||||
a.Refusals = nil
|
||||
}
|
||||
}
|
||||
|
||||
// maxCulprits bounds the modules one refusal is tried on, each a whole composition.
|
||||
const maxCulprits = 8
|
||||
|
||||
// MaxShown bounds the bytes of shown resource bodies in one answer (nodeengine.CandidateDiffers.Bodies).
|
||||
const MaxShown = 256 << 10
|
||||
|
||||
var (
|
||||
grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`)
|
||||
// grantOfStandIn is the provision a grant's stand-in is of.
|
||||
grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`)
|
||||
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
|
||||
)
|
||||
|
||||
// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port
|
||||
// `port module:port`, a grant `the grant of <provision> for <module> on <node>`, a need `<module> on <node> needs ...`;
|
||||
// the module empty when the words name none (the certificate, the authority).
|
||||
func standsInFor(x, node string) (string, string) {
|
||||
if m := grantStandIn.FindStringSubmatch(x); m != nil {
|
||||
return m[2], m[1]
|
||||
}
|
||||
if m := needsStandIn.FindStringSubmatch(x); m != nil {
|
||||
return m[2], m[1]
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(x, "port "); ok {
|
||||
if module, _, ok := strings.Cut(rest, ":"); ok {
|
||||
return node, module
|
||||
}
|
||||
}
|
||||
if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") {
|
||||
return node, module
|
||||
}
|
||||
return node, ""
|
||||
}
|
||||
|
||||
// composeEach is the one rule, for the node that applies and for its answer to a candidate alike, by which a module's
|
||||
// own refusal refuses that module and never the node (issue 1126: seed-8 fixed it in the answer alone, and the
|
||||
// applying path still refused all of novox for de-spiegel's unset route names). The node is composed; a refusal is laid
|
||||
// on the module whose stub (compose.Stub) changes it (culprit), that module is held (compose.Node.Held) and said in
|
||||
// failed with the refusal's words, and the rest is composed again, until it composes or the refusal is no single
|
||||
// module's, which is answered as the node's (err). Applied composes as the node applies (compose.Reading) and holds the
|
||||
// module placed here each stand-in is for; one for no module placed here (the node's own, a value this node holds for a
|
||||
// module elsewhere) refuses the node (compose.ErrStoodIn). Not applied, it composes in compose.Foreseeing and answers
|
||||
// every stand-in for the caller to judge (Answer).
|
||||
//
|
||||
// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it
|
||||
// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others
|
||||
// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own
|
||||
// composition is in out: the node keeps what it last applied.
|
||||
//
|
||||
// **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held
|
||||
// route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer,
|
||||
// a collector, the provider of what the held one contributes to and the filter where it listens each compose with what
|
||||
// the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the
|
||||
// build it runs where one is known (ran: a composition of its own running at another build than it is composed at),
|
||||
// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision
|
||||
// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account,
|
||||
// and the hold says so.
|
||||
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []compositions.Composed, failed map[string]string,
|
||||
ran func(module string) (catalogue.Manifest, bool)) (out map[string]compositions.Composed, skipped, stood []string, err error) {
|
||||
mode := compose.Foreseeing
|
||||
if applied {
|
||||
mode = compose.Reading
|
||||
}
|
||||
runs := map[string]*compositions.Composed{}
|
||||
for i := range running {
|
||||
if m := running[i].Module; m != "" {
|
||||
runs[m] = &running[i]
|
||||
}
|
||||
}
|
||||
src := map[string]catalogue.Manifest{} // each held module's manifest before it was held, offering what it last offered
|
||||
left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut)
|
||||
hold := func(m, why, leave string) {
|
||||
if _, done := failed[m]; !done {
|
||||
failed[m] = why // a held module tried again keeps the words it was first held for
|
||||
}
|
||||
if _, ok := src[m]; !ok {
|
||||
man, ok := manifestOn(in, node, m)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ran != nil {
|
||||
if was, ok := ran(m); ok {
|
||||
man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens
|
||||
}
|
||||
}
|
||||
src[m] = man
|
||||
}
|
||||
if leave != "" {
|
||||
if left[m] == nil {
|
||||
left[m] = map[string]bool{}
|
||||
}
|
||||
left[m][leave] = true
|
||||
if leave != leaveAll && leave != leaveOwn {
|
||||
failed[m] += "; composed without what it contributes to " + leave + ", which does not compose"
|
||||
}
|
||||
}
|
||||
in = heldAs(in, node, m, src[m], left[m], runs[m])
|
||||
}
|
||||
for _, m := range sortedKeys(failed) {
|
||||
hold(m, failed[m], "")
|
||||
}
|
||||
for {
|
||||
out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode)
|
||||
if err != nil {
|
||||
module, leave := culprit(in, node, fps, mode, failed, err)
|
||||
if module == "" {
|
||||
return nil, nil, nil, firstReason(err, failed)
|
||||
}
|
||||
hold(module, err.Error(), leave)
|
||||
continue
|
||||
}
|
||||
again := false
|
||||
if applied && len(stood) > 0 {
|
||||
placed := map[string]bool{}
|
||||
for _, m := range assignedOn(in, node) {
|
||||
placed[m] = true
|
||||
}
|
||||
of := map[string][]string{}
|
||||
var nodes []string
|
||||
for _, x := range stood {
|
||||
if n, m := standsInFor(x, node); n == node && placed[m] {
|
||||
of[m] = append(of[m], x)
|
||||
} else {
|
||||
nodes = append(nodes, x)
|
||||
}
|
||||
}
|
||||
for _, m := range sortedKeys(of) {
|
||||
if _, done := failed[m]; !done {
|
||||
hold(m, "stands in for "+strings.Join(of[m], ", "), "")
|
||||
again = true
|
||||
continue
|
||||
}
|
||||
// Already held, and still a stand-in in another module's composition: a grant not yet issued for what
|
||||
// it contributes (a module held for its own secret keeps its contributions, leaveOwn). Its contribution
|
||||
// to that provision is left out, so the provider never applies a stand-in (issue 774, review round 1).
|
||||
for _, x := range of[m] {
|
||||
if g := grantOfStandIn.FindStringSubmatch(x); g != nil && !left[m][g[1]] {
|
||||
hold(m, failed[m], g[1])
|
||||
again = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !again && len(nodes) > 0 {
|
||||
return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", "))
|
||||
}
|
||||
}
|
||||
if !again {
|
||||
break
|
||||
}
|
||||
}
|
||||
skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held })
|
||||
return out, skipped, stood, nil
|
||||
}
|
||||
|
||||
// firstReason is err, a refusal no culprit is found for, led by the first reason of each held module it names: a held
|
||||
// module's stand-in refused later never replaces why it was held (issue 774, 2026-10-30: mesh-self-check on ace held for
|
||||
// "has no bus credential", then refused as "runs as its own account and declares no own secret broker", its bare stub's
|
||||
// words, which were all the proposal's refusal said).
|
||||
func firstReason(err error, failed map[string]string) error {
|
||||
var named []string
|
||||
for _, m := range sortedKeys(failed) {
|
||||
if strings.Contains(err.Error(), m) {
|
||||
named = append(named, m+" is held: "+failed[m])
|
||||
}
|
||||
}
|
||||
if len(named) == 0 {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err)
|
||||
}
|
||||
|
||||
// blames says the composition with m's stub (again) makes m the culprit of err, m the tool runner (runner) or not: it composes, or fails otherwise. The
|
||||
// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is
|
||||
// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it).
|
||||
func blames(runner bool, again, err error) bool {
|
||||
if runner {
|
||||
// Or where its stub fails only for the modules of their own account it would run (issue 774): they are held
|
||||
// after it, each for its own words, and the node composes the rest.
|
||||
var none *resolution.NoRuntimeToRunError
|
||||
return again == nil || errors.As(again, &none) && none.Held
|
||||
}
|
||||
return again == nil || again.Error() != err.Error()
|
||||
}
|
||||
|
||||
// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets
|
||||
// alone, its offer kept; any other word leaves out what it contributes to that provision alone.
|
||||
//
|
||||
// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774,
|
||||
// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its
|
||||
// login while it is held.
|
||||
const (
|
||||
leaveAll = "*"
|
||||
leaveOwn = "own-secrets"
|
||||
)
|
||||
|
||||
// assignedOn is the modules placed on node, in assignment order.
|
||||
func assignedOn(in compose.Input, node string) []string {
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == node {
|
||||
return n.Assigned
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// manifestOn is the manifest node composes module from: its Kept, else the catalogue's.
|
||||
func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) {
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == node {
|
||||
if man, ok := n.Kept[module]; ok {
|
||||
return man, true
|
||||
}
|
||||
}
|
||||
}
|
||||
man, ok := in.Catalogue[module]
|
||||
return man, ok
|
||||
}
|
||||
|
||||
// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the
|
||||
// first, in assignment order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
|
||||
// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's
|
||||
// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its
|
||||
// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not
|
||||
// one module's). Modules already refused are passed over for their stub.
|
||||
func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) {
|
||||
assigned := assignedOn(in, node)
|
||||
// Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a
|
||||
// refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's
|
||||
// answer took 7m46s).
|
||||
var order []string
|
||||
for _, m := range assigned {
|
||||
if strings.Contains(err.Error(), m) && len(order) < maxCulprits {
|
||||
order = append(order, m)
|
||||
}
|
||||
}
|
||||
changes := func(m, leave string) bool {
|
||||
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
|
||||
man, known := manifestOn(in, node, m)
|
||||
return blames(known && catalogue.IsToolRunner(man), again, err)
|
||||
}
|
||||
for _, m := range order {
|
||||
if _, done := failed[m]; done {
|
||||
continue
|
||||
}
|
||||
if changes(m, "") {
|
||||
return m, ""
|
||||
}
|
||||
}
|
||||
for _, m := range order {
|
||||
man, ok := manifestOn(in, node, m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// One already held is tried again too: held for its build, its stub may still refuse (its own secret never
|
||||
// issued, issue 1126). A provision named in the error is tried first, each at one more composition.
|
||||
var to []string
|
||||
for p := range man.Contributes {
|
||||
to = append(to, p)
|
||||
}
|
||||
for p := range man.ContributesMany {
|
||||
if _, both := man.Contributes[p]; !both {
|
||||
to = append(to, p)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(to, func(i, j int) bool {
|
||||
return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j])
|
||||
})
|
||||
for _, p := range to {
|
||||
if changes(m, p) {
|
||||
return m, p
|
||||
}
|
||||
}
|
||||
if isBare(man) {
|
||||
continue
|
||||
}
|
||||
if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) {
|
||||
return m, leaveOwn
|
||||
}
|
||||
if changes(m, leaveAll) {
|
||||
return m, leaveAll
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll).
|
||||
func isBare(m catalogue.Manifest) bool {
|
||||
return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0
|
||||
}
|
||||
|
||||
// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node
|
||||
// composes it from, leave left out of its offer: a culprit's try.
|
||||
func stubbed(in compose.Input, node, module, leave string) compose.Input {
|
||||
man, ok := manifestOn(in, node, module)
|
||||
if !ok {
|
||||
return in
|
||||
}
|
||||
var left map[string]bool
|
||||
if leave != "" {
|
||||
left = map[string]bool{leave: true}
|
||||
}
|
||||
return heldAs(in, node, module, man, left, nil)
|
||||
}
|
||||
|
||||
// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of
|
||||
// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of
|
||||
// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes
|
||||
// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's
|
||||
// repository on a node it never ran on) is no refusal of the rest.
|
||||
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *compositions.Composed) compose.Input {
|
||||
out := in
|
||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
for i := range out.Nodes {
|
||||
if out.Nodes[i].Name != node {
|
||||
continue
|
||||
}
|
||||
if left[leaveAll] {
|
||||
man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil
|
||||
} else if len(left) > 0 {
|
||||
if left[leaveOwn] {
|
||||
man.OwnSecrets = nil
|
||||
}
|
||||
contributes := map[string]map[string]any{}
|
||||
for p, v := range man.Contributes {
|
||||
if !left[p] {
|
||||
contributes[p] = v
|
||||
}
|
||||
}
|
||||
many := maps.Clone(man.ContributesMany)
|
||||
for p := range left {
|
||||
delete(many, p)
|
||||
}
|
||||
man.Contributes, man.ContributesMany = contributes, many
|
||||
}
|
||||
kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1)
|
||||
for k, v := range out.Nodes[i].Kept {
|
||||
kept[k] = v
|
||||
}
|
||||
kept[module] = compose.Carried(man, module, runs)
|
||||
out.Nodes[i].Kept = kept
|
||||
held := make(map[string]bool, len(out.Nodes[i].Held)+1)
|
||||
for k, v := range out.Nodes[i].Held {
|
||||
held[k] = v
|
||||
}
|
||||
held[module] = true
|
||||
out.Nodes[i].Held = held
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only;
|
||||
// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos,
|
||||
// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs.
|
||||
func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
image := "alpine@sha256:" + strings.Repeat("a", 64)
|
||||
manifest := func(module string) []byte {
|
||||
// Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused
|
||||
// "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory).
|
||||
return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` +
|
||||
`"own-secrets":{"token":"${dir:state}/token"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` +
|
||||
`{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` +
|
||||
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
|
||||
}
|
||||
lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
|
||||
fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) }
|
||||
mk := func(placed ...string) *bus {
|
||||
p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
for _, m := range placed {
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m})
|
||||
}
|
||||
b := &bus{assignments: map[string]meshmoduleassigner.Assignments{"n1": p},
|
||||
records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
|
||||
modules: map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)}},
|
||||
manifests: map[string][]byte{meshcatalogclient.ManifestDigest(lemurs): lemurs},
|
||||
builds: map[string]*builds.BuilderBuild{}}
|
||||
for _, m := range []string{"web", "api", "spiegel"} {
|
||||
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
|
||||
}
|
||||
for _, m := range []string{"web", "api", "spiegel"} {
|
||||
raw := manifest(m)
|
||||
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
}
|
||||
// web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of
|
||||
// the new form at all.
|
||||
for i, m := range []string{"web", "api"} {
|
||||
mod := b.modules[m]
|
||||
mod.Fp2 = fp(string(rune('1' + i)))
|
||||
b.modules[m] = mod
|
||||
b.builds[m+" "+mod.Fp2] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
|
||||
}
|
||||
return b
|
||||
}
|
||||
given := func(b *bus) map[string]*meshmoduleassigner.Assignments {
|
||||
p := b.assignments["n1"]
|
||||
return map[string]*meshmoduleassigner.Assignments{"n1": &p}
|
||||
}
|
||||
|
||||
// Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
|
||||
b := mk("lemurs", "web", "api", "spiegel")
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b)},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
var refused []string
|
||||
for _, r := range a.Refusals {
|
||||
refused = append(refused, r.Module)
|
||||
}
|
||||
if a.Verdict != nodeengine.CandidateAnswerVerdictRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
|
||||
t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a)
|
||||
}
|
||||
for _, r := range a.Refusals {
|
||||
want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site",
|
||||
"spiegel": "spiegel has not been built for this mesh"}[r.Module]
|
||||
if !strings.Contains(r.Why, want) {
|
||||
t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want)
|
||||
}
|
||||
}
|
||||
if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil {
|
||||
t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
|
||||
}
|
||||
if lemursOK := slices.ContainsFunc(a.Added, func(m nodeengine.CandidateAdded) bool { return m.Module == "lemurs" }); !lemursOK {
|
||||
t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a)
|
||||
}
|
||||
// Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every
|
||||
// refused module held today the verdict is no refusal, and each is named under Held.
|
||||
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
if today.Verdict == nodeengine.CandidateAnswerVerdictRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
|
||||
t.Errorf("held as today: %+v", today)
|
||||
}
|
||||
// A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused.
|
||||
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), HeldToday: map[string]string{"api": "held"}},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
if part.Verdict != nodeengine.CandidateAnswerVerdictRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
|
||||
t.Errorf("held as today in part: %+v", part)
|
||||
}
|
||||
// No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold.
|
||||
if a := (nodeengine.CandidateAnswer{Refusals: []nodeengine.CandidateRefusal{{Module: "de-spiegel", Why: "a value is unset"},
|
||||
{Module: "mailu", Why: "no build"}}}); true {
|
||||
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
|
||||
if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 {
|
||||
t.Errorf("held as today spreads: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
|
||||
// they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
|
||||
b = mk("lemurs", overlay.Name, "spiegel")
|
||||
p := b.assignments["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.assignments["n1"] = p
|
||||
fps := map[string]string{"lemurs": fp("e")}
|
||||
stub := Scope{Node: "n1"}
|
||||
in, err := Input(ctx, b, Mesh{}, stub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
|
||||
base, _, err := compose.Compositions(in, "n1", fps, compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's
|
||||
// answer took 7m46s, one compose per module), though here a stub would compose.
|
||||
if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" {
|
||||
t.Errorf("a refusal naming no placed module was laid on %s", m)
|
||||
}
|
||||
ownKey, _ := engcomp.NodeKey("n1")
|
||||
own := base[ownKey]
|
||||
carriedRes := []json.RawMessage{
|
||||
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
|
||||
// They run after the node's first own resource.
|
||||
own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...)
|
||||
var running []engcomp.Composed
|
||||
for k, c := range base {
|
||||
if k == ownKey {
|
||||
c = own
|
||||
}
|
||||
running = append(running, c)
|
||||
}
|
||||
scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}
|
||||
got, _, held, err := Compose(ctx, b, Mesh{}, scope, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err)
|
||||
}
|
||||
gotOwn, _ := json.Marshal(got[ownKey].Resources)
|
||||
wantOwn, _ := json.Marshal(own.Resources)
|
||||
if string(gotOwn) != string(wantOwn) {
|
||||
t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
|
||||
}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
|
||||
if a.Verdict != nodeengine.CandidateAnswerVerdictSame {
|
||||
t.Errorf("the fold's answer: %+v, want the same", a)
|
||||
}
|
||||
// (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
|
||||
// module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
|
||||
keptToo := append(append([]engcomp.Composed{}, running...), engcomp.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame || len(a.Removed) != 0 {
|
||||
t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
|
||||
}
|
||||
// (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
|
||||
// the same, not 'in what runs, not composed' (seed #531, novox).
|
||||
membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`)
|
||||
withMembership := make([]engcomp.Composed, 0, len(running))
|
||||
for _, c := range running {
|
||||
if c.Module == "" {
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership)
|
||||
}
|
||||
withMembership = append(withMembership, c)
|
||||
}
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame {
|
||||
t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
|
||||
}
|
||||
// Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
|
||||
if _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil ||
|
||||
!strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") {
|
||||
t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held)
|
||||
}
|
||||
// Issue 1585 (nats on novox, met twice): wanted at a build of the new form, a module in InOwn is no longer folded
|
||||
// (compose.Folds), so its build is taken in and it composes with its image, never from the indexer's manifest as
|
||||
// "not built for this mesh".
|
||||
{
|
||||
bb := mk("lemurs", overlay.Name, "spiegel")
|
||||
bp := bb.assignments["n1"]
|
||||
bp.Decided.Endpoint = "n1.example:51820"
|
||||
bb.assignments["n1"] = bp
|
||||
sp := bb.modules["spiegel"]
|
||||
sp.Fp2 = fp("3")
|
||||
bb.modules["spiegel"] = sp
|
||||
image := "registry.example:5100/spiegel/site@sha256:" + strings.Repeat("7", 64)
|
||||
bb.builds["spiegel "+sp.Fp2] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: sp.Manifest,
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "site", Kind: "upstream", Reference: image, Digest: "sha256:" + strings.Repeat("7", 64)}}}
|
||||
read, err := ComposingInput(ctx, bb, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}},
|
||||
map[string]string{"lemurs": fp("e"), "spiegel": sp.Fp2})
|
||||
raw, _ := json.Marshal(read.Catalogue["spiegel"])
|
||||
if err != nil || read.ComposedHow.Held["spiegel"] != "" || !strings.Contains(string(raw), strings.Repeat("7", 64)) {
|
||||
t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.Fp2, err,
|
||||
read.ComposedHow, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
|
||||
// opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
|
||||
// stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file
|
||||
// a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export.
|
||||
listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` +
|
||||
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` +
|
||||
`{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` +
|
||||
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
|
||||
nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`)
|
||||
vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`)
|
||||
b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault")
|
||||
for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} {
|
||||
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.assignments["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p.Modules {
|
||||
if p.Modules[i].Module == "spiegel" {
|
||||
p.Modules[i].Ports = map[string]int64{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b.assignments["n1"] = p
|
||||
rec := b.records["n1"]
|
||||
rec.OutwardLinks = []string{"eth0"}
|
||||
b.records["n1"] = rec
|
||||
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")}
|
||||
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
|
||||
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`)
|
||||
running = nil
|
||||
for _, c := range base {
|
||||
switch c.Module {
|
||||
case "":
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...),
|
||||
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:`+strings.Repeat("c", 64)+`","ports":["10.77.0.1:20005:8080"]}`),
|
||||
json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`))
|
||||
case "vault":
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile)
|
||||
}
|
||||
running = append(running, c)
|
||||
}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("the node does not compose itself with spiegel carried: %v", err)
|
||||
}
|
||||
var rules string
|
||||
var keptComposed json.RawMessage
|
||||
for _, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
switch r.ID {
|
||||
case "nft." + catalogue.FilteringID():
|
||||
rules = r.Content
|
||||
case "vault." + resolution.KeptID():
|
||||
keptComposed = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") {
|
||||
t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules)
|
||||
}
|
||||
// The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue
|
||||
// 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and
|
||||
// files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools'
|
||||
// MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is
|
||||
// composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container).
|
||||
for _, c := range running {
|
||||
if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) {
|
||||
t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths)
|
||||
}
|
||||
}
|
||||
f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f1, err := catalogue.Decode(f1raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f1runs := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}}
|
||||
if got, want := resolution.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), resolution.DeclaredPaths(f1); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want)
|
||||
}
|
||||
// Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node:
|
||||
// "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it
|
||||
// contributes to other modules' collections still reaches them.
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
handed *catalogue.Handed
|
||||
items []catalogue.Item
|
||||
}{
|
||||
{"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"},
|
||||
Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}},
|
||||
{"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}},
|
||||
} {
|
||||
kept := f1
|
||||
kept.Handed, kept.Items = row.handed, row.items
|
||||
st := compose.Stub(kept)
|
||||
if st.Handed != nil && st.Handed.Templates != nil {
|
||||
t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates)
|
||||
}
|
||||
if !reflect.DeepEqual(st.Items, row.items) {
|
||||
t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items)
|
||||
}
|
||||
if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) {
|
||||
t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items)
|
||||
}
|
||||
}
|
||||
var k1, k2 any
|
||||
_ = json.Unmarshal(keptComposed, &k1)
|
||||
_ = json.Unmarshal(keptFile, &k2)
|
||||
if !reflect.DeepEqual(k1, k2) {
|
||||
t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile)
|
||||
}
|
||||
|
||||
// (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs
|
||||
// de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values
|
||||
// for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing
|
||||
// praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is
|
||||
// refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread
|
||||
// to mailu (its smtp provider) and proxy (its route provider), and from them on. Neither is held now: proxy composes
|
||||
// without de-spiegel's routes, which do not compose, and mailu with its smtp account, which does.
|
||||
spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`)
|
||||
mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`)
|
||||
b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel")
|
||||
for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} {
|
||||
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.assignments["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.assignments["n1"] = p
|
||||
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
|
||||
refused = nil
|
||||
for _, r := range a.Refusals {
|
||||
refused = append(refused, r.Module)
|
||||
if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) {
|
||||
t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why)
|
||||
}
|
||||
}
|
||||
if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) {
|
||||
t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a)
|
||||
}
|
||||
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||
if !slices.ContainsFunc(a.Added, func(x nodeengine.CandidateAdded) bool { return x.Module == m }) {
|
||||
t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a)
|
||||
}
|
||||
}
|
||||
|
||||
// (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It
|
||||
// composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was
|
||||
// marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which
|
||||
// receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it
|
||||
// removes nothing of it.
|
||||
spiegelRuns := []json.RawMessage{
|
||||
json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`),
|
||||
json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`),
|
||||
json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
|
||||
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])}
|
||||
in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest {
|
||||
m.Contributes, m.ContributesMany = nil, nil
|
||||
return m
|
||||
}(in.Nodes[0].Kept["de-spiegel"])
|
||||
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
running = nil
|
||||
for _, c := range base {
|
||||
if c.Module == "" {
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...)
|
||||
}
|
||||
running = append(running, c)
|
||||
}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps)
|
||||
if err != nil {
|
||||
t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err)
|
||||
}
|
||||
if len(held) != 1 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["de-spiegel"], "without what it contributes to route") {
|
||||
t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held)
|
||||
}
|
||||
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||
if key, _ := engcomp.Key("n1", m, fps[m]); got[key].Module != m {
|
||||
t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got))
|
||||
}
|
||||
}
|
||||
carries := func(c engcomp.Composed, what string) {
|
||||
t.Helper()
|
||||
ownRes, _ := json.Marshal(c.Resources)
|
||||
for _, raw := range spiegelRuns {
|
||||
var r struct{ ID string }
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) {
|
||||
t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
carries(got[ownKey], "folded")
|
||||
// (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form,
|
||||
// so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried
|
||||
// in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its
|
||||
// container, files and units (issue 1945 by a new path).
|
||||
fps["de-spiegel"] = fp("7")
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
|
||||
if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") {
|
||||
t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held)
|
||||
}
|
||||
spiegelKey, _ := engcomp.Key("n1", "de-spiegel", fps["de-spiegel"])
|
||||
if _, ok := got[spiegelKey]; ok {
|
||||
t.Errorf("held de-spiegel's own composition is handed")
|
||||
}
|
||||
carries(got[ownKey], "leaving the fold")
|
||||
|
||||
// (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others
|
||||
// compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own
|
||||
// secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which
|
||||
// reads what site listens on, composes with it (issue 1960: a hold never spreads).
|
||||
site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` +
|
||||
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` +
|
||||
`{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`)
|
||||
b = mk("lemurs", overlay.Name, "site", "nft")
|
||||
for m, raw := range map[string][]byte{"site": site, "nft": nft} {
|
||||
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.assignments["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p.Modules {
|
||||
if p.Modules[i].Module == "site" {
|
||||
p.Modules[i].Ports = map[string]int64{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b.assignments["n1"] = p
|
||||
rec = b.records["n1"]
|
||||
rec.OutwardLinks = []string{"eth0"}
|
||||
b.records["n1"] = rec
|
||||
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")}
|
||||
siteRuns := engcomp.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps)
|
||||
nftKey, _ := engcomp.Key("n1", "nft", fps["nft"])
|
||||
if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" {
|
||||
t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held)
|
||||
}
|
||||
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 20005") {
|
||||
t.Errorf("the filter drops held site's opening where it runs (20005): %s", rules)
|
||||
}
|
||||
for key, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) {
|
||||
t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
// (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and
|
||||
// app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres
|
||||
// consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them").
|
||||
provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1))
|
||||
app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`)
|
||||
b2 := mk("lemurs", overlay.Name, "site", "nft", "app")
|
||||
for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} {
|
||||
b2.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
b2.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
}
|
||||
p2 := b2.assignments["n1"]
|
||||
p2.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p2.Modules {
|
||||
if p2.Modules[i].Module == "site" {
|
||||
p2.Modules[i].Ports = map[string]int64{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b2.assignments["n1"] = p2
|
||||
b2.records["n1"] = rec
|
||||
b2.held = true
|
||||
fps["app"] = fp("9")
|
||||
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps)
|
||||
appKey, _ := engcomp.Key("n1", "app", fps["app"])
|
||||
if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed {
|
||||
t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held)
|
||||
}
|
||||
if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") {
|
||||
t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound)
|
||||
}
|
||||
delete(fps, "app")
|
||||
// A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's
|
||||
// routes, its names unset, leave the proxy without them, and its smtp account stays with mailu.
|
||||
withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{},
|
||||
Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}}
|
||||
feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}},
|
||||
OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}}
|
||||
for _, row := range []struct {
|
||||
left map[string]bool
|
||||
route, smtp, own bool
|
||||
}{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} {
|
||||
k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"]
|
||||
if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own {
|
||||
t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets)
|
||||
}
|
||||
}
|
||||
// (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its
|
||||
// manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the
|
||||
// build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build
|
||||
// that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090.
|
||||
moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1))
|
||||
b.manifests[meshcatalogclient.ManifestDigest(moved)] = moved
|
||||
b.modules["site"] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(moved)}
|
||||
for i := range p.Modules {
|
||||
p.Modules[i].Ports = nil
|
||||
}
|
||||
b.assignments["n1"] = p
|
||||
siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`)
|
||||
siteRuns.FP = fp("7")
|
||||
b.manifests[meshcatalogclient.ManifestDigest(site)] = site
|
||||
b.builds["site "+fp("7")] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(site)}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps)
|
||||
if err != nil || held["site"] == "" || held["nft"] != "" {
|
||||
t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held)
|
||||
}
|
||||
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 8080") {
|
||||
t.Errorf("the filter drops what held site last offered (8080): %s", rules)
|
||||
}
|
||||
for key, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
if strings.Contains(string(raw), "dport 9090") {
|
||||
t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module another node cannot run in this composition offers nothing in it: the refusal names it and why
|
||||
// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a
|
||||
// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module
|
||||
// (ace took 7m46s).
|
||||
func otherNodeNotResolvedIsNamed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`)
|
||||
cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`)
|
||||
// n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here.
|
||||
b := &bus{assignments: map[string]meshmoduleassigner.Assignments{
|
||||
"n1": {Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
|
||||
"n2": {Node: "n2", Decided: meshmoduleassigner.NodeDecided{Address: "10.77.0.2"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
|
||||
records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
|
||||
modules: map[string]meshcatalog.ModulesByModuleValue{"prov": {Manifest: meshcatalogclient.ManifestDigest(prov)}, "cons": {Manifest: meshcatalogclient.ManifestDigest(cons)}},
|
||||
manifests: map[string][]byte{meshcatalogclient.ManifestDigest(prov): prov, meshcatalogclient.ManifestDigest(cons): cons}}
|
||||
given := map[string]*meshmoduleassigner.Assignments{}
|
||||
for n, p := range b.assignments {
|
||||
p := p
|
||||
given[n] = &p
|
||||
}
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
|
||||
if a.Verdict != nodeengine.CandidateAnswerVerdictRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
|
||||
t.Errorf("a refusal for want of n2's provider = %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered
|
||||
// "reaches no artifact store", and nothing said why).
|
||||
func noStoreReachedSaysWhy(t *testing.T) {
|
||||
in := compose.Input{Catalogue: map[string]catalogue.Manifest{
|
||||
"distribution": {Module: "distribution", Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}},
|
||||
// A module serving the retired provision artifact-store is no store (ADR 0632 §1).
|
||||
"old-store": {Module: "old-store", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
|
||||
Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita", "old-store"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
|
||||
if got := storeSought(in); got != "the composition holds g14, novox; container-image-registry is held by distribution on novox, none of it on the private network with a listen open to the mesh" {
|
||||
t.Errorf("said %q", got)
|
||||
}
|
||||
in.Nodes = in.Nodes[:1]
|
||||
if got := storeSought(in); got != "the composition holds g14; no module placed there holds container-image-registry" {
|
||||
t.Errorf("said %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
sdkkeys "git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
)
|
||||
|
||||
// Export is one node's assignment as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
|
||||
// §7): the inverse of Input for what a person decides, so the controller's one read-only export verb
|
||||
// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay
|
||||
// stable for the same store, since adopt checks its digest again at the warrant.
|
||||
// What the machine observes (its record) is not in it.
|
||||
func Export(in compose.Input, node string) (meshmoduleassigner.Assignments, error) {
|
||||
var n *compose.Node
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name == node {
|
||||
n = &in.Nodes[i]
|
||||
}
|
||||
}
|
||||
if n == nil {
|
||||
return meshmoduleassigner.Assignments{}, fmt.Errorf("frombus: the input has no node %q", node)
|
||||
}
|
||||
// At the version a assignment key is read at, with a list of modules even when none is placed: the export is the
|
||||
// seed's value, and the reader refuses a value of version 0 or without its modules.
|
||||
p := meshmoduleassigner.Assignments{V: sdkkeys.Version, Node: node, Modules: []meshmoduleassigner.PlacedModule{}, Decided: meshmoduleassigner.NodeDecided{
|
||||
Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address,
|
||||
PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount,
|
||||
AgentAccountHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}}
|
||||
// The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted
|
||||
// one whether or not the hub took it; and the names a person gave the hub's carried peers.
|
||||
if t, ok := in.Tunnels[node]; ok {
|
||||
rt := recordTunnelOf(t.Tunnel)
|
||||
p.Decided.Tunnel = &rt
|
||||
} else if in.Adopted != nil && in.Adopted.Hub == node {
|
||||
rt := recordTunnelOf(in.Adopted.Tunnel)
|
||||
p.Decided.Tunnel = &rt
|
||||
}
|
||||
if n.Place.Hub {
|
||||
for _, c := range in.CarriedPeers {
|
||||
if c.Named != "" {
|
||||
if p.Decided.PeerNames == nil {
|
||||
p.Decided.PeerNames = map[string]string{}
|
||||
}
|
||||
p.Decided.PeerNames[c.PublicKey] = c.Named
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, module := range n.Assigned {
|
||||
m := meshmoduleassigner.PlacedModule{Module: module}
|
||||
for port, at := range n.Ports[module] {
|
||||
if m.Ports == nil {
|
||||
m.Ports = map[string]int64{}
|
||||
}
|
||||
m.Ports[strconv.Itoa(port)] = int64(at)
|
||||
}
|
||||
for provision, to := range n.Bound[module] {
|
||||
if m.Bindings == nil {
|
||||
m.Bindings = map[string]meshmoduleassigner.Provider{}
|
||||
}
|
||||
m.Bindings[provision] = meshmoduleassigner.Provider{Node: to.Node, Module: to.Module}
|
||||
}
|
||||
// A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with
|
||||
// the assignment. A module in the config form has mesh-config's values laid into its layer, which are not.
|
||||
settingsForm := !in.Catalogue[module].DeclaresConfig()
|
||||
for _, layer := range n.Settings[module] {
|
||||
if layer.From == catalogue.MeshWideLayer {
|
||||
m.HasMeshWide, m.MeshWide = true, layer.Placement
|
||||
if settingsForm && len(layer.Values) > 0 {
|
||||
m.MeshWideValues = layer.Values
|
||||
}
|
||||
} else {
|
||||
m.HasLayer, m.Layer = true, layer.Placement
|
||||
if settingsForm && len(layer.Values) > 0 {
|
||||
m.Values = layer.Values
|
||||
}
|
||||
}
|
||||
}
|
||||
p.Modules = append(p.Modules, m)
|
||||
}
|
||||
for provision, to := range n.Pins {
|
||||
if p.Pins == nil {
|
||||
p.Pins = map[string]meshmoduleassigner.Provider{}
|
||||
}
|
||||
p.Pins[provision] = meshmoduleassigner.Provider{Node: to.Node, Module: to.Module}
|
||||
}
|
||||
for _, h := range in.Holdings {
|
||||
if h.Node == node {
|
||||
p.Holds = append(p.Holds, meshmoduleassigner.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(p.Holds, func(i, j int) bool {
|
||||
if p.Holds[i].Claim != p.Holds[j].Claim {
|
||||
return p.Holds[i].Claim < p.Holds[j].Claim
|
||||
}
|
||||
return p.Holds[i].Module < p.Holds[j].Module
|
||||
})
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func recordTunnelOf(t compose.Tunnel) meshmoduleassigner.FoundTunnel {
|
||||
out := meshmoduleassigner.FoundTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: int64(t.Port), Mtu: int64(t.MTU),
|
||||
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
|
||||
for _, peer := range t.Peers {
|
||||
out.Peers = append(out.Peers, meshmoduleassigner.FoundTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address})
|
||||
}
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,912 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"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"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
)
|
||||
|
||||
// bus is a Reader over values held in memory, counting whose config values were read.
|
||||
type bus struct {
|
||||
vaultAuthority string
|
||||
handed map[string]bool
|
||||
assignments map[string]meshmoduleassigner.Assignments
|
||||
records map[string]nodeengine.NodeRecord
|
||||
modules map[string]meshcatalog.ModulesByModuleValue
|
||||
manifests map[string][]byte
|
||||
values map[string]map[string]any // by node.module
|
||||
valuesRead []string
|
||||
own []compose.OwnSecretRow
|
||||
ownErr error
|
||||
// asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming
|
||||
// module its assignment does not put on the node.
|
||||
asked []compose.CredentialKey
|
||||
// builds are the builder's keys, by "<module> <fp>"; archives the artifact store's blobs, by digest, and fetched
|
||||
// every URL asked; served another node's values by "<node>.<module>", and servedRead every ask with its keys.
|
||||
builds map[string]*builds.BuilderBuild
|
||||
archives map[string][]byte
|
||||
fetched []string
|
||||
served map[string]map[string]any
|
||||
servedRead []string
|
||||
buildsRead []string
|
||||
// held answers every credential asked as held, sealed to the consumer.
|
||||
held bool
|
||||
}
|
||||
|
||||
func (b *bus) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) {
|
||||
b.buildsRead = append(b.buildsRead, module)
|
||||
return b.builds[module+" "+fp], nil
|
||||
}
|
||||
|
||||
func (b *bus) Archive(_ context.Context, url, digest string) ([]byte, error) {
|
||||
b.fetched = append(b.fetched, url)
|
||||
body, ok := b.archives[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("the store holds no such blob")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (b *bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
|
||||
b.servedRead = append(b.servedRead, node+"."+module+":"+strings.Join(keys, ","))
|
||||
out := map[string]any{}
|
||||
for _, k := range keys {
|
||||
if v, ok := b.served[node+"."+module][k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *bus) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) {
|
||||
return b.assignments, nil
|
||||
}
|
||||
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
|
||||
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
|
||||
func (b *bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
|
||||
return b.records, nil
|
||||
}
|
||||
func (b *bus) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) {
|
||||
return b.modules, nil
|
||||
}
|
||||
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
raw, ok := b.manifests[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("no such manifest")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
|
||||
return b.own, b.ownErr
|
||||
}
|
||||
|
||||
func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
b.asked = append(b.asked, asConsumer...)
|
||||
for _, k := range asConsumer {
|
||||
placed := false
|
||||
for _, m := range b.assignments[node].Modules {
|
||||
placed = placed || m.Module == k.ConsumerModule
|
||||
}
|
||||
if !placed {
|
||||
return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node)
|
||||
}
|
||||
}
|
||||
var out []compose.Credential
|
||||
var issued []compose.Issued
|
||||
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
|
||||
if b.held {
|
||||
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
|
||||
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
|
||||
}
|
||||
}
|
||||
return out, issued, nil
|
||||
}
|
||||
|
||||
func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
b.valuesRead = append(b.valuesRead, node+"."+module)
|
||||
return b.values[node+"."+module], nil
|
||||
}
|
||||
|
||||
func publicKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
// Issue 1126 (S3, S4, S5): the bus's assignment, node records, manifests and config values fill the same compose.Input the
|
||||
// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input
|
||||
// composes; a node reads only its own config values; what cannot be composed is refused, naming it.
|
||||
func TestInput(t *testing.T) {
|
||||
// Issue 1126, the seed's shadow: every refusal named, an old-form module left to the node, and no store reached
|
||||
// saying why (answer_rows_test.go).
|
||||
t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode)
|
||||
t.Run("no store reached says why", noStoreReachedSaysWhy)
|
||||
// Issue 774: a node refused whole says what it held on the way.
|
||||
t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld)
|
||||
t.Run("a refused manifest never ends the read", aRefusedManifestNeverEndsTheRead)
|
||||
t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes)
|
||||
t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild)
|
||||
t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads)
|
||||
t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode)
|
||||
t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed)
|
||||
t.Run("what runs is kept while a manifest does not say it", whatRunsIsKeptWhileAManifestDoesNotSayIt)
|
||||
ctx := context.Background()
|
||||
xterm, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := meshcatalogclient.ManifestDigest(xterm)
|
||||
// A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its
|
||||
// layers, not mesh-config, so the assignment carries them (MeshValues, Values) and mesh-config's are not read for it.
|
||||
media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` +
|
||||
`"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` +
|
||||
`"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`)
|
||||
mediaDigest := meshcatalogclient.ManifestDigest(media)
|
||||
keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)}
|
||||
caps := []string{"container-runtime", "systemd", "wireguard"}
|
||||
tunnel := &meshmoduleassigner.FoundTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
|
||||
Peers: []meshmoduleassigner.FoundTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
|
||||
mk := func() *bus {
|
||||
return &bus{
|
||||
assignments: map[string]meshmoduleassigner.Assignments{
|
||||
"anchor": {Node: "anchor", Decided: meshmoduleassigner.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1",
|
||||
Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}},
|
||||
Modules: []meshmoduleassigner.PlacedModule{{Module: overlay.Name}},
|
||||
Holds: []meshmoduleassigner.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}},
|
||||
"laptop": {Node: "laptop", Decided: meshmoduleassigner.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
|
||||
Modules: []meshmoduleassigner.PlacedModule{{Module: overlay.Name},
|
||||
{Module: "xterm", HasLayer: true, Ports: map[string]int64{"80": 8080}},
|
||||
{Module: "media", HasMeshWide: true, MeshWideValues: map[string]any{"name": "mesh"}, HasLayer: true,
|
||||
Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}},
|
||||
},
|
||||
records: map[string]nodeengine.NodeRecord{
|
||||
"anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2},
|
||||
"laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"},
|
||||
},
|
||||
modules: map[string]meshcatalog.ModulesByModuleValue{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}},
|
||||
manifests: map[string][]byte{digest: xterm, mediaDigest: media},
|
||||
values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"},
|
||||
"laptop.media": {"lines": "not mesh-config's"}},
|
||||
own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
|
||||
}
|
||||
}
|
||||
|
||||
// What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it.
|
||||
var private catalogue.Manifest
|
||||
raw, _ := json.Marshal(overlay.Manifest())
|
||||
if err := json.Unmarshal(raw, &private); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := catalogue.Decode(xterm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedMedia, err := catalogue.Decode(media)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true}
|
||||
tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
|
||||
Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
|
||||
want := compose.Input{
|
||||
Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia},
|
||||
Nodes: []compose.Node{
|
||||
{Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true,
|
||||
Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true},
|
||||
{Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here",
|
||||
Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet,
|
||||
Ports: map[string]map[int]int{"xterm": {80: 8080}},
|
||||
Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}},
|
||||
"media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}},
|
||||
{From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}},
|
||||
},
|
||||
Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}},
|
||||
OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
|
||||
// The same value as a core credential's box, for a core module (ADR 0349 §16).
|
||||
KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}},
|
||||
Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}},
|
||||
Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true},
|
||||
// The controller's order is by address; laptop enrolled under its peer's key, and a person named the other.
|
||||
Authority: "the mesh's certificate",
|
||||
CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"},
|
||||
{PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}},
|
||||
}
|
||||
|
||||
b := mk()
|
||||
got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gj, _ := json.MarshalIndent(got, "", " ")
|
||||
wj, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's
|
||||
// and the vault's; with none, the controller's alone, as above.
|
||||
signing := mk()
|
||||
signing.vaultAuthority = "the vault's certificate"
|
||||
if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil ||
|
||||
bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" {
|
||||
t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err)
|
||||
}
|
||||
// Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's.
|
||||
judged := mk()
|
||||
judged.handed = map[string]bool{"media": true, "xterm": false}
|
||||
if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil ||
|
||||
!reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil {
|
||||
t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err)
|
||||
}
|
||||
if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
|
||||
t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead)
|
||||
}
|
||||
|
||||
// The same composer over both: the compositions from the bus are the store's, byte for byte.
|
||||
fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64),
|
||||
"media": "src2:" + strings.Repeat("c", 64)}
|
||||
fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) {
|
||||
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
|
||||
}
|
||||
|
||||
// Issue 1126: a candidate assignment equal to the node's composes what it runs, and CompareComposed says so; one
|
||||
// thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves
|
||||
// here. Placed puts the same assignment over the store's input unchanged.
|
||||
running := make([]compositions.Composed, 0, len(fromBus))
|
||||
for _, c := range fromBus {
|
||||
running = append(running, c)
|
||||
}
|
||||
laptop := mk().assignments["laptop"]
|
||||
asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": &laptop}}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := compositions.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
|
||||
t.Errorf("the candidate equal to the assignment: %+v", c)
|
||||
}
|
||||
moved := mk().assignments["anchor"]
|
||||
moved.Decided.Endpoint = "anchor.example:51821"
|
||||
changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": &laptop, "anchor": &moved}}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := compositions.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
|
||||
t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name)
|
||||
}
|
||||
if c := compositions.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
|
||||
t.Errorf("nothing runs: %+v", c)
|
||||
}
|
||||
|
||||
// ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not
|
||||
// yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes.
|
||||
keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`)
|
||||
lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
|
||||
// A provider and a consumer of a pair credential on the one node (compose's objects and files).
|
||||
objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}})
|
||||
files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
|
||||
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}})
|
||||
one := func(placed ...string) *bus {
|
||||
p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
for _, m := range placed {
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m})
|
||||
}
|
||||
return &bus{assignments: map[string]meshmoduleassigner.Assignments{"n1": p},
|
||||
records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]meshcatalog.ModulesByModuleValue{"keeper": {Manifest: meshcatalogclient.ManifestDigest(keeper)}, "lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)},
|
||||
"objects": {Manifest: meshcatalogclient.ManifestDigest(objects)}, "files": {Manifest: meshcatalogclient.ManifestDigest(files)}},
|
||||
manifests: map[string][]byte{meshcatalogclient.ManifestDigest(keeper): keeper, meshcatalogclient.ManifestDigest(lemurs): lemurs,
|
||||
meshcatalogclient.ManifestDigest(objects): objects, meshcatalogclient.ManifestDigest(files): files},
|
||||
own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}}
|
||||
}
|
||||
oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64),
|
||||
"objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)}
|
||||
runs, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runsNow []compositions.Composed
|
||||
for _, c := range runs {
|
||||
runsNow = append(runsNow, c)
|
||||
}
|
||||
objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var consumerRuns []compositions.Composed
|
||||
for _, c := range objectsRun {
|
||||
consumerRuns = append(consumerRuns, c)
|
||||
}
|
||||
withoutOwn := func(cs []compositions.Composed) []compositions.Composed {
|
||||
var out []compositions.Composed
|
||||
for _, c := range cs {
|
||||
if c.Module != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// What the controller runs on n1 for a module no want names (lemurs, at a build of the old form) and the private
|
||||
// network's (built by nobody): its composed writer (mesh-controller compositionsOf) folds their resources into the
|
||||
// node's own, each in its place, its module taken off; the rest split by module.
|
||||
tunnelled := func() *bus {
|
||||
b := one("keeper", "lemurs", overlay.Name)
|
||||
p := b.assignments["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.assignments["n1"] = p
|
||||
return b
|
||||
}
|
||||
tunnelledAs := func() map[string]*meshmoduleassigner.Assignments {
|
||||
p := tunnelled().assignments["n1"]
|
||||
return map[string]*meshmoduleassigner.Assignments{"n1": &p}
|
||||
}
|
||||
folded := func() []compositions.Composed {
|
||||
b := tunnelled()
|
||||
in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reading := in
|
||||
reading.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
reading.Nodes[0].ReadsModules = true
|
||||
d, _, err := compose.ComposeIn(reading, "n1", compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope struct {
|
||||
Resources []map[string]json.RawMessage `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resources []json.RawMessage
|
||||
for _, r := range envelope.Resources {
|
||||
var module string
|
||||
_ = json.Unmarshal(r["module"], &module)
|
||||
if module == "lemurs" || module == overlay.Name {
|
||||
delete(r, "module")
|
||||
}
|
||||
raw, _ := json.Marshal(r)
|
||||
resources = append(resources, raw)
|
||||
}
|
||||
split, err := compositions.Split("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, compositions.NodeOwn{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []compositions.Composed
|
||||
for _, c := range split {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
if got := InOwn(folded, tunnelled().assignments["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) {
|
||||
t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name)
|
||||
}
|
||||
placedAs := func(modules ...string) map[string]*meshmoduleassigner.Assignments {
|
||||
p := one(modules...).assignments["n1"]
|
||||
return map[string]*meshmoduleassigner.Assignments{"n1": &p}
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
b *bus
|
||||
given map[string]*meshmoduleassigner.Assignments
|
||||
running []compositions.Composed
|
||||
verdict string
|
||||
check func(nodeengine.CandidateAnswer) bool
|
||||
}{
|
||||
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, nodeengine.CandidateAnswerVerdictSame,
|
||||
func(a nodeengine.CandidateAnswer) bool {
|
||||
return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7
|
||||
}},
|
||||
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), nodeengine.CandidateAnswerVerdictSame,
|
||||
func(a nodeengine.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, nodeengine.CandidateAnswerVerdictDiffers,
|
||||
func(a nodeengine.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
|
||||
"one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
nil, nodeengine.CandidateAnswerVerdictDiffers, func(a nodeengine.CandidateAnswer) bool {
|
||||
for _, m := range a.Added {
|
||||
if m.Module == "keeper" {
|
||||
return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}},
|
||||
"a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool {
|
||||
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password")
|
||||
}},
|
||||
// Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an
|
||||
// ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued.
|
||||
"a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, nodeengine.CandidateAnswerVerdictDiffers,
|
||||
func(a nodeengine.CandidateAnswer) bool {
|
||||
return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0
|
||||
}},
|
||||
// And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition.
|
||||
"the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool {
|
||||
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0
|
||||
}},
|
||||
"cannot compose": {one("keeper"), map[string]*meshmoduleassigner.Assignments{"n1": {Node: "n2"}}, runsNow, nodeengine.CandidateAnswerVerdictRefused,
|
||||
func(a nodeengine.CandidateAnswer) bool { return a.Refused != "" }},
|
||||
// Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want
|
||||
// names and the controller folds into the node's own, are folded there again: the node's own is the same, and
|
||||
// neither is added.
|
||||
"mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(),
|
||||
folded, nodeengine.CandidateAnswerVerdictSame, func(a nodeengine.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
} {
|
||||
fps := oneFps
|
||||
if name == "mesh-wireguard and an old-form module, folded" {
|
||||
fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network
|
||||
}
|
||||
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Assignments: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
|
||||
if a.Verdict != c.verdict || !c.check(a) {
|
||||
t.Errorf("%s: %+v", name, a)
|
||||
}
|
||||
if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil {
|
||||
t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw)
|
||||
}
|
||||
}
|
||||
if got, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Placed with the node's own assignment changes the input: %v", err)
|
||||
}
|
||||
if got, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
|
||||
t.Errorf("Placed with no assignment: %+v, %v", got.Nodes[1], err)
|
||||
}
|
||||
if _, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"stranger": {Node: "stranger"}}); err == nil {
|
||||
t.Error("Placed a node the input holds no node for")
|
||||
}
|
||||
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": {Node: "anchor"}}}); err == nil {
|
||||
t.Error("a given assignment naming another node was taken")
|
||||
}
|
||||
|
||||
// ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
|
||||
// assignment the bus holds, and read back it is the store's input again.
|
||||
for node, placed := range mk().assignments {
|
||||
got, err := Export(want, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gj, _ := meshmoduleassignerclient.EncodeAssignments(got); !meshmoduleassignerclient.SameAssignments(got, placed) {
|
||||
pj, _ := meshmoduleassignerclient.EncodeAssignments(placed)
|
||||
t.Errorf("%s exported as %s, want %s", node, gj, pj)
|
||||
}
|
||||
}
|
||||
if _, err := Export(want, "stranger"); err == nil {
|
||||
t.Error("a node the input lacks was exported")
|
||||
}
|
||||
|
||||
b = mk()
|
||||
if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil {
|
||||
t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets)
|
||||
}
|
||||
if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
|
||||
// Every node's placed modules, and nothing placed nowhere: anchor places no xterm.
|
||||
t.Errorf("the check read %v", b.valuesRead)
|
||||
}
|
||||
|
||||
// A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node
|
||||
// with no secret composed nothing while the vault did not answer); one whose module does, refuses without it.
|
||||
quiet := mk()
|
||||
quiet.ownErr = errors.New("no responders")
|
||||
if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil {
|
||||
t.Errorf("anchor, with no own secret, needed the vault: %v", err)
|
||||
}
|
||||
if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil {
|
||||
t.Error("laptop's media, which declares an own secret, composed without the vault")
|
||||
}
|
||||
|
||||
// A module the node's want keeps at another build than the trunk's composes from that build's manifest, resolved with
|
||||
// what it made (issue 1126), on the composing node alone.
|
||||
kept := mk()
|
||||
oldXterm := []byte(`{"module":"xterm","version":"1","resources":[{"id":"tools","type":"process","name":"xterm-tools",` +
|
||||
`"artifact":"tools","run":["./xterm-tools"]}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/xterm-tools","binary":"xterm-tools"}]}}`)
|
||||
kept.manifests["old"] = oldXterm
|
||||
oldFP := "src2:" + strings.Repeat("9", 64)
|
||||
toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64)
|
||||
kept.builds = map[string]*builds.BuilderBuild{"xterm " + oldFP: {State: builds.BuildDone, Manifest: "old",
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}}
|
||||
parsedOld, err := catalogue.Decode(oldXterm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keptBuilt, err := parsedOld.Resolve([]catalogue.Built{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": oldFP}})
|
||||
if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": keptBuilt}) || in.Nodes[0].Kept != nil {
|
||||
t.Errorf("the want's build is kept on the composing node alone: %v, %v", in.Nodes, err)
|
||||
}
|
||||
|
||||
// Issue 1126, the seed's refusals. restic and the provisioner's client as the catalogue holds them (mesh-catalog
|
||||
// c05aa5e6), restic moved with an own secret the vault leaves out (it hands it to the client, mesh-vault ownnode.go
|
||||
// handed): composed from the indexer's manifest restic is not handed and stands in for that secret, as novox's,
|
||||
// ace's, g14's and shanks's answers refused; composed from its build, it is handed, its templates archive read
|
||||
// where the node reaches the artifact store, and the composition is the one the controller's take-in gives.
|
||||
resticRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := []byte(`{"module":"store","version":"1",` +
|
||||
`"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`)
|
||||
texts := map[string]string{}
|
||||
var packed bytes.Buffer
|
||||
zw := gzip.NewWriter(&packed)
|
||||
tw := tar.NewWriter(zw)
|
||||
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
|
||||
text, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.files/" + f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
texts[f] = string(text)
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(text); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archiveDigest := meshcatalogclient.ManifestDigest(packed.Bytes())
|
||||
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
|
||||
"store": "src2:" + strings.Repeat("5", 64)}
|
||||
resticArtifacts := []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}}
|
||||
clientArtifacts := []builds.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}
|
||||
movedMesh := func(built bool) *bus {
|
||||
b := one()
|
||||
p := b.assignments["n1"]
|
||||
p.Modules = []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}
|
||||
p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator"
|
||||
b.assignments["n1"] = p
|
||||
rec := b.records["n1"]
|
||||
rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"}
|
||||
b.records["n1"] = rec
|
||||
b.modules = map[string]meshcatalog.ModulesByModuleValue{}
|
||||
b.manifests = map[string][]byte{}
|
||||
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} {
|
||||
b.modules[module] = meshcatalog.ModulesByModuleValue{Fp2: movedFps[module], Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
}
|
||||
// The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out.
|
||||
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
|
||||
if built {
|
||||
b.builds = map[string]*builds.BuilderBuild{
|
||||
"restic " + movedFps["restic"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: resticArtifacts},
|
||||
"mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw), Artifacts: clientArtifacts},
|
||||
"store " + movedFps["store"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)},
|
||||
}
|
||||
b.archives = map[string][]byte{archiveDigest: packed.Bytes()}
|
||||
}
|
||||
return b
|
||||
}
|
||||
if out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) {
|
||||
t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held)
|
||||
} else {
|
||||
for key, c := range out {
|
||||
if c.Module == "restic" {
|
||||
// Its stub's composition, applied, would remove what restic runs: a held module keeps its last one.
|
||||
t.Errorf("held restic is composed as %s, want none of its own composition handed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
builtBus := movedMesh(true)
|
||||
fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("restic composed from its build: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) {
|
||||
t.Errorf("the templates archive was read at %v", builtBus.fetched)
|
||||
}
|
||||
resticKey, _ := compositions.Key("n1", "restic", movedFps["restic"])
|
||||
if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret
|
||||
t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled)
|
||||
}
|
||||
// A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8).
|
||||
unreferenced := movedMesh(true)
|
||||
unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]}
|
||||
if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") {
|
||||
t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held)
|
||||
}
|
||||
// A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done
|
||||
// build of it is at the trunk's FP2: its build is not read.
|
||||
folding := movedMesh(true)
|
||||
foldIn, err := Input(ctx, folding, Mesh{}, Scope{Node: "n1", InOwn: []string{"mesh-provisioner-client"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientParsed, err := catalogue.Decode(clientRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(folding.buildsRead, "mesh-provisioner-client") || !reflect.DeepEqual(foldIn.Catalogue["mesh-provisioner-client"], clientParsed) {
|
||||
t.Errorf("a folded module was taken from its build: builds read %v", folding.buildsRead)
|
||||
}
|
||||
// The controller's: its take-in resolves each manifest with the key's artifacts and the archive's texts, and composes
|
||||
// from those over the same input.
|
||||
storeIn, err := Input(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for module, built := range map[string][]catalogue.Built{
|
||||
"restic": {{Name: "tools", Kind: "bundle", Reference: resticArtifacts[0].Reference, Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: resticArtifacts[1].Reference, Digest: archiveDigest, Texts: texts}},
|
||||
"mesh-provisioner-client": {{Name: "client", Kind: "bundle", Reference: clientArtifacts[0].Reference, Digest: digestOf("7")}},
|
||||
} {
|
||||
resolved, err := storeIn.Catalogue[module].Resolve(built)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Kept as its bytes and read again, as the controller's store holds a build's manifest.
|
||||
raw, _ := json.Marshal(resolved)
|
||||
if storeIn.Catalogue[module], err = catalogue.Decode(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
storeIn.OwnSecrets = movedMesh(true).own
|
||||
fromStoreMoved, _, err := compose.Compositions(storeIn, "n1", movedFps, compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(fromBuild, fromStoreMoved) {
|
||||
gj, _ := json.MarshalIndent(fromBuild, "", " ")
|
||||
wj, _ := json.MarshalIndent(fromStoreMoved, "", " ")
|
||||
t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no
|
||||
// secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets
|
||||
// (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one.
|
||||
noSecrets := movedMesh(true)
|
||||
noSecrets.own = nil
|
||||
seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatalf("the plans from the bus: %v", err)
|
||||
}
|
||||
controllers, err := plansIn(storeIn, "n1", compose.Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) {
|
||||
gj, _ := json.MarshalIndent(seat, "", " ")
|
||||
wj, _ := json.MarshalIndent(controllers, "", " ")
|
||||
t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures.
|
||||
items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil || len(unplaced) > 0 || len(items) == 0 {
|
||||
t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err)
|
||||
}
|
||||
for module, its := range items {
|
||||
for _, it := range its {
|
||||
if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" {
|
||||
t.Errorf("%s's item %+v is not placed", module, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for
|
||||
// mesh-provisioner and disk-load, the lead 2026-10-29).
|
||||
viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) {
|
||||
for _, body := range noSecrets.archives {
|
||||
return body, nil
|
||||
}
|
||||
return nil, errors.New("no archive")
|
||||
}), Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil || !reflect.DeepEqual(viaStates, items) {
|
||||
t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items)
|
||||
}
|
||||
|
||||
// And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused
|
||||
// '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served.
|
||||
mailer := []byte(`{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` +
|
||||
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`)
|
||||
withMailer := func(set bool) *bus {
|
||||
b := mk()
|
||||
p := b.assignments["anchor"]
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "mailer"})
|
||||
b.assignments["anchor"] = p
|
||||
b.modules["mailer"] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(mailer)}
|
||||
b.manifests[meshcatalogclient.ManifestDigest(mailer)] = mailer
|
||||
if set {
|
||||
b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}}
|
||||
}
|
||||
return b
|
||||
}
|
||||
if _, _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) {
|
||||
t.Errorf("a provider's served setting nobody sets: %v", err)
|
||||
}
|
||||
served := withMailer(true)
|
||||
if _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 {
|
||||
t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held)
|
||||
}
|
||||
if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") {
|
||||
t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead)
|
||||
}
|
||||
|
||||
for name, c := range map[string]struct {
|
||||
change func(*bus)
|
||||
scope Scope
|
||||
says string
|
||||
}{
|
||||
"no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
|
||||
"no assignment": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no assignment"},
|
||||
"a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
|
||||
"a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"},
|
||||
} {
|
||||
b := mk()
|
||||
c.change(b)
|
||||
if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapStates are read states by name, key to value, as a module's runtime gives them.
|
||||
type mapStates map[string]map[string][]byte
|
||||
|
||||
func (m mapStates) Get(state, key string) ([]byte, bool, error) {
|
||||
v, ok := m[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (m mapStates) Keys(state string) ([]string, error) {
|
||||
var out []string
|
||||
for k := range m[state] {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// statesOf is b laid out as the states StatesReader reads.
|
||||
func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
|
||||
t.Helper()
|
||||
out := mapStates{}
|
||||
put := func(state, key string, raw []byte, err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out[state] == nil {
|
||||
out[state] = map[string][]byte{}
|
||||
}
|
||||
out[state][key] = raw
|
||||
}
|
||||
assignments, _ := b.Assignments(ctx)
|
||||
for _, p := range assignments {
|
||||
values, err := meshmoduleassignerclient.AssignmentsValues(p)
|
||||
for k, raw := range values {
|
||||
put(AssignmentsRead, k, raw, err)
|
||||
}
|
||||
}
|
||||
records, _ := b.Records(ctx)
|
||||
for node, r := range records {
|
||||
k, _ := nodeengineclient.HaveNodeKey(node)
|
||||
rec := r
|
||||
raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec})
|
||||
put(HaveRead, k, raw, err)
|
||||
}
|
||||
modules, _ := b.Modules(ctx)
|
||||
for name, m := range modules {
|
||||
raw, err := meshcatalogclient.EncodeModule(m)
|
||||
put(ModulesRead, name, raw, err)
|
||||
var mf struct{ Module string }
|
||||
_ = json.Unmarshal(b.manifests[m.Manifest], &mf)
|
||||
k, _ := meshcatalogclient.ManifestsKey(m.Manifest)
|
||||
raw, err = meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: mf.Module, Manifest: string(b.manifests[m.Manifest])})
|
||||
put(ManifestsRead, k, raw, err)
|
||||
}
|
||||
for key, build := range b.builds {
|
||||
module, fp, _ := strings.Cut(key, " ")
|
||||
k, _ := builds.BuilderBuildKey(module, fp)
|
||||
raw, err := builds.EncodeBuilderBuild(*build)
|
||||
put(BuildsRead, k, raw, err)
|
||||
}
|
||||
for nm, values := range b.values {
|
||||
for k, v := range values {
|
||||
value, _ := json.Marshal(v)
|
||||
raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule})
|
||||
put(ResolvedRead, nm+"."+k, raw, err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ADR 0601 §4: the new node agent meets the tool runner's older manifest (no tool-runner) while the node runs its runtime
|
||||
// process, and the provisioner's older manifest (no core-credentials) while its own secret runs as the vault's box: each is
|
||||
// composed as what runs shows, said, by what runs and never by a name; a manifest that says it, or nothing running so, marks
|
||||
// nothing.
|
||||
func whatRunsIsKeptWhileAManifestDoesNotSayIt(t *testing.T) {
|
||||
running := []compositions.Composed{
|
||||
{Node: "a", Module: "runner-x", Resources: []json.RawMessage{json.RawMessage(`{"id":"runner-x.runtime","type":"process"}`)}},
|
||||
{Node: "a", Module: "prov-y", Resources: []json.RawMessage{json.RawMessage(`{"id":"prov-y.need-broker","type":"file","vault-sealed":{"box":"b"}}`)}},
|
||||
{Node: "a", Module: "app", Resources: []json.RawMessage{json.RawMessage(`{"id":"app.need-broker","type":"file"}`)}},
|
||||
}
|
||||
input := func(runnerSays bool) compose.Input {
|
||||
return compose.Input{Nodes: []compose.Node{{Name: "a", Assigned: []string{"app", "prov-y", "runner-x"}}},
|
||||
Catalogue: map[string]catalogue.Manifest{"runner-x": {Module: "runner-x", ToolRunner: runnerSays}, "prov-y": {Module: "prov-y"},
|
||||
"app": {Module: "app"}}}
|
||||
}
|
||||
in := input(false)
|
||||
said := keepWhatRuns(&in, "a", running)
|
||||
if !in.Catalogue["runner-x"].ToolRunner || !in.Catalogue["prov-y"].CoreCredentials || in.Catalogue["app"].ToolRunner ||
|
||||
in.Catalogue["app"].CoreCredentials || len(said) != 2 || said[0].Module != "prov-y" || said[1].Module != "runner-x" {
|
||||
t.Errorf("what runs was not kept: %+v, said %q", in.Catalogue, said)
|
||||
}
|
||||
in = input(true)
|
||||
if said := keepWhatRuns(&in, "a", running[:1]); len(said) != 0 {
|
||||
t.Errorf("a manifest that says it is the tool runner was marked again: %q", said)
|
||||
}
|
||||
in = input(false)
|
||||
if said := keepWhatRuns(&in, "a", nil); len(said) != 0 || in.Catalogue["runner-x"].ToolRunner {
|
||||
t.Errorf("nothing running marked %q", said)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the
|
||||
// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR
|
||||
// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog
|
||||
// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more.
|
||||
package frombustest
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/frombus"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var testdata embed.FS
|
||||
|
||||
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
|
||||
type Bus struct {
|
||||
ByNode map[string]meshmoduleassigner.Assignments
|
||||
Record map[string]nodeengine.NodeRecord
|
||||
Module map[string]meshcatalog.ModulesByModuleValue
|
||||
Manifests map[string][]byte
|
||||
Value map[string]map[string]any // by node.module
|
||||
Own []compose.OwnSecretRow
|
||||
Builds map[string]*builds.BuilderBuild // by "<module> <fp>"
|
||||
Archives map[string][]byte // by digest
|
||||
Serves map[string]map[string]any // another node's values, by node.module
|
||||
Held bool
|
||||
}
|
||||
|
||||
var _ frombus.Reader = (*Bus)(nil)
|
||||
|
||||
func (b *Bus) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) {
|
||||
return b.ByNode, nil
|
||||
}
|
||||
func (b *Bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
|
||||
return b.Record, nil
|
||||
}
|
||||
func (b *Bus) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) {
|
||||
return b.Module, nil
|
||||
}
|
||||
func (b *Bus) VaultAuthority(context.Context) string { return "" }
|
||||
|
||||
func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
raw, ok := b.Manifests[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("no such manifest")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
return b.Value[node+"."+module], nil
|
||||
}
|
||||
|
||||
func (b *Bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.Own, nil }
|
||||
|
||||
func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
var out []compose.Credential
|
||||
var issued []compose.Issued
|
||||
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
|
||||
if b.Held {
|
||||
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
|
||||
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
|
||||
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
|
||||
}
|
||||
}
|
||||
return out, issued, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) {
|
||||
return b.Builds[module+" "+fp], nil
|
||||
}
|
||||
|
||||
func (b *Bus) Archive(_ context.Context, _, digest string) ([]byte, error) {
|
||||
body, ok := b.Archives[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("the store holds no such blob")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
|
||||
out := map[string]any{}
|
||||
for _, k := range keys {
|
||||
if v, ok := b.Serves[node+"."+module][k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Node is the one node of the mesh Provisioned composes.
|
||||
const Node = "n1"
|
||||
|
||||
// Provisioned is one node, n1, that runs the artifact store, a postgres providing postgres-database, the provisioner's
|
||||
// client and restic: restic built and handed to the provisioner, and requiring postgres-database. Its plans (frombus.Plans
|
||||
// with Scope{Node: Node}) hand restic, whose plan names that provision with postgres on n1 as its provider.
|
||||
func Provisioned() (*Bus, error) {
|
||||
read := func(name string) ([]byte, error) { return testdata.ReadFile("testdata/" + name) }
|
||||
resticRaw, err := read("catalogue-c05aa5e6-restic.module.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var restic map[string]any
|
||||
if err := json.Unmarshal(resticRaw, &restic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
restic["requires"] = []string{"postgres-database"}
|
||||
if resticRaw, err = json.Marshal(restic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientRaw, err := read("catalogue-c05aa5e6-mesh-provisioner-client.module.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := []byte(`{"module":"store","version":"1",` +
|
||||
`"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`)
|
||||
postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` +
|
||||
`"serves":{"postgres-database":{"port":5432}}}`)
|
||||
|
||||
var packed bytes.Buffer
|
||||
zw := gzip.NewWriter(&packed)
|
||||
tw := tar.NewWriter(zw)
|
||||
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
|
||||
text, err := read("catalogue-c05aa5e6-restic.files/" + f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tw.Write(text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archive := meshcatalogclient.ManifestDigest(packed.Bytes())
|
||||
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
fps := FPs()
|
||||
b := &Bus{
|
||||
ByNode: map[string]meshmoduleassigner.Assignments{Node: {Node: Node,
|
||||
Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
|
||||
Modules: []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
|
||||
Record: map[string]nodeengine.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
|
||||
SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
|
||||
Module: map[string]meshcatalog.ModulesByModuleValue{}, Manifests: map[string][]byte{},
|
||||
Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
|
||||
Builds: map[string]*builds.BuilderBuild{
|
||||
"restic " + fps["restic"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: []builds.BuiltArtifact{
|
||||
{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}},
|
||||
"mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw),
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "client", Kind: "bundle",
|
||||
Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}},
|
||||
"store " + fps["store"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)},
|
||||
"postgres " + fps["postgres"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(postgres)},
|
||||
},
|
||||
Archives: map[string][]byte{archive: packed.Bytes()},
|
||||
Held: true,
|
||||
}
|
||||
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} {
|
||||
b.Module[module] = meshcatalog.ModulesByModuleValue{Fp2: fps[module], Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
b.Manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// FPs are the fingerprints Provisioned's modules are built at.
|
||||
func FPs() map[string]string {
|
||||
return map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
|
||||
"store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package frombustest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose/frombus"
|
||||
"github.com/novox/mesh-host/internal/compose/fromruntime"
|
||||
)
|
||||
|
||||
// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the
|
||||
// provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node
|
||||
// (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld
|
||||
// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer
|
||||
// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33,
|
||||
// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node
|
||||
// it is not placed on holds nothing.
|
||||
func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
fp := "src2:" + strings.Repeat("9", 64)
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
bad string // the indexer's manifest of a module "backup" placed on n1; empty places none
|
||||
// built is the manifest backup's done build names; empty gives it no build
|
||||
built string
|
||||
why string // words backup's hold says
|
||||
// elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held.
|
||||
elsewhere bool
|
||||
// refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds
|
||||
// backup too, and never ends the read.
|
||||
refused string
|
||||
// dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build,
|
||||
// an item composition refuses), not one only a plan meets.
|
||||
dataHeld bool
|
||||
// dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item.
|
||||
dataOnly bool
|
||||
}{
|
||||
{name: "every module composes"},
|
||||
{name: "a built manifest that cannot be used",
|
||||
bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`,
|
||||
built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true},
|
||||
{name: "a plan that refuses",
|
||||
bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` +
|
||||
`"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"},
|
||||
{name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`,
|
||||
refused: `"shows" is none of them`, dataHeld: true},
|
||||
// ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read.
|
||||
{name: "a data item its composition refuses",
|
||||
bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`,
|
||||
why: "nope", dataHeld: true, dataOnly: true},
|
||||
{name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true,
|
||||
refused: `"shows" is none of them`},
|
||||
} {
|
||||
b, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.bad != "" {
|
||||
if !row.elsewhere {
|
||||
p := b.ByNode[Node]
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "backup"})
|
||||
b.ByNode[Node] = p
|
||||
}
|
||||
digest := meshcatalogclient.ManifestDigest([]byte(row.bad))
|
||||
b.Manifests[digest] = []byte(row.bad)
|
||||
b.Module["backup"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest}
|
||||
if row.refused != "" {
|
||||
b.Module["backup"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Verdict: meshcatalogclient.VerdictRefused,
|
||||
Problems: []string{row.refused}}
|
||||
}
|
||||
if row.built != "" {
|
||||
builtDigest := meshcatalogclient.ManifestDigest([]byte(row.built))
|
||||
b.Manifests[builtDigest] = []byte(row.built)
|
||||
b.Builds["backup "+fp] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: builtDigest,
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
|
||||
}
|
||||
if row.dataOnly {
|
||||
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 {
|
||||
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
switch {
|
||||
case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")):
|
||||
t.Errorf("%s: Plans did not hold the refused manifest alone: %v %v", row.name, plans, err)
|
||||
case row.refused == "" && err == nil && !row.elsewhere:
|
||||
t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name)
|
||||
}
|
||||
}
|
||||
if row.why != "" || row.bad == "" {
|
||||
// The node's own composition holds by the same rule: every other module composed, the bad one named.
|
||||
fps := FPs()
|
||||
if row.bad != "" {
|
||||
fps["backup"] = fp
|
||||
}
|
||||
out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: the node's composition: %v", row.name, err)
|
||||
}
|
||||
if key, _ := engcomp.Key(Node, "restic", fps["restic"]); out[key].Module != "restic" {
|
||||
t.Errorf("%s: restic is not composed (held %v)", row.name, held)
|
||||
}
|
||||
if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) {
|
||||
t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why)
|
||||
}
|
||||
}
|
||||
plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
restic, handed := plans["restic"]
|
||||
if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" ||
|
||||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
|
||||
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
|
||||
}
|
||||
// Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each,
|
||||
// and where the time went is said by phase.
|
||||
keptAt := FPs()
|
||||
at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
|
||||
wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
|
||||
if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) ||
|
||||
!reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) {
|
||||
t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held)
|
||||
}
|
||||
if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") {
|
||||
t.Errorf("%s: the phases are said as %q", row.name, said)
|
||||
}
|
||||
if row.bad == "" || row.elsewhere {
|
||||
if len(held) > 0 {
|
||||
t.Errorf("%s: held %v", row.name, held)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if why, ok := held["backup"]; !ok || !strings.Contains(why, row.why) || len(held) != 1 {
|
||||
t.Errorf("%s: held %v, want backup alone, saying %q", row.name, held, row.why)
|
||||
}
|
||||
if _, planned := plans["backup"]; planned {
|
||||
t.Errorf("%s: the held module has a plan", row.name)
|
||||
}
|
||||
// The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole.
|
||||
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
// A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file.
|
||||
mustHold := row.dataHeld
|
||||
if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil {
|
||||
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is
|
||||
// asked, and a manifest once per digest.
|
||||
byKey := map[string]int{}
|
||||
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
|
||||
byKey[state+" watched"]++
|
||||
return handle(stdio.StateChange{Key: Node + ".node", Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
|
||||
}, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil })
|
||||
w := fromruntime.Watched{WatchedStates: ws}
|
||||
for range 2 {
|
||||
_, _, _ = w.Get(frombus.BuildsRead, "restic."+fp)
|
||||
_, _, _ = w.Get(frombus.ManifestsRead, "sha256-d")
|
||||
if _, found, _ := w.Get(frombus.AssignmentsRead, Node+".node"); !found {
|
||||
t.Error("the watched reader does not answer the assignment from its copy")
|
||||
}
|
||||
}
|
||||
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.AssignmentsRead] != 0 ||
|
||||
byKey[frombus.AssignmentsRead+" watched"] != 1 {
|
||||
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the assignment watched once", byKey)
|
||||
}
|
||||
fetched := 0
|
||||
keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil })
|
||||
for range 2 {
|
||||
if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" {
|
||||
t.Fatalf("the kept fetch answered %q, %v", body, err)
|
||||
}
|
||||
}
|
||||
if fetched != 1 {
|
||||
t.Errorf("a templates archive was fetched %d times, want once", fetched)
|
||||
}
|
||||
|
||||
// Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again
|
||||
// and the plans are the same.
|
||||
b, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) {
|
||||
fetched++
|
||||
_, digest, _ := strings.Cut(url, "/blobs/")
|
||||
return b.Archives[digest], nil
|
||||
}), fetched
|
||||
var first map[string]catalogue.Plan
|
||||
for i := range 2 {
|
||||
plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") {
|
||||
t.Fatalf("composed %v (held %v): %v", plans, held, err)
|
||||
}
|
||||
first = plans
|
||||
}
|
||||
if fetched-archives != 1 {
|
||||
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
|
||||
}
|
||||
|
||||
// Issue 774 (ace, 2026-11-04 01:29): the provisioner client held as its stub (its build not done, so the node keeps
|
||||
// running what it runs) keeps the account the node's own composition names, and restic stays handed to it with its
|
||||
// plan; without it every filled write was refused and no module was handed.
|
||||
if b, err = Provisioned(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fps, ownKey := FPs(), ""
|
||||
ownKey, _ = engcomp.NodeKey(Node)
|
||||
resticKey, _ := engcomp.Key(Node, "restic", fps["restic"])
|
||||
before, _, _, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
|
||||
account := before[ownKey].Own.Provisioner
|
||||
if err != nil || account == "" || len(before[resticKey].Filled) == 0 {
|
||||
t.Fatalf("the node composes with its client: account %q, restic's filled files %v: %v", account, before[resticKey].Filled, err)
|
||||
}
|
||||
var running []engcomp.Composed
|
||||
for _, c := range before {
|
||||
running = append(running, c)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
own, placed bool // the account read from the running own composition, else the client's running process; the client placed
|
||||
userless bool // the client placed and not held, its process naming no user: it composes no account
|
||||
// filledHeld holds every module with a filled file (restic, its build not done) and not the client: the node still
|
||||
// names the account (issue 774, ace 2026-11-04: its build modules and ssh-client held, no filled file, no account)
|
||||
filledHeld bool
|
||||
}{{true, true, false, false}, {false, true, false, false}, {true, false, false, false}, {false, false, false, false},
|
||||
{true, true, true, false}, {true, true, false, true}} {
|
||||
own := row.own
|
||||
runs := running
|
||||
if !own { // what runs names no account of its own: the client's running process says it
|
||||
runs = nil
|
||||
for _, c := range running {
|
||||
if c.Module == "" {
|
||||
c.Own.Provisioner = ""
|
||||
}
|
||||
runs = append(runs, c)
|
||||
}
|
||||
}
|
||||
bb, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := account
|
||||
if row.userless {
|
||||
// placed and not held, composing no account of its own: no stale account is kept from what runs
|
||||
mod := bb.Module["mesh-provisioner-client"]
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(bb.Manifests[mod.Manifest], &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m["resources"].([]any) {
|
||||
delete(r.(map[string]any), "user")
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
mod.Manifest = meshcatalogclient.ManifestDigest(raw)
|
||||
bb.Module["mesh-provisioner-client"], bb.Manifests[mod.Manifest] = mod, raw
|
||||
built := *bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]]
|
||||
built.Manifest = mod.Manifest
|
||||
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &built
|
||||
want = ""
|
||||
} else if row.filledHeld {
|
||||
bb.Builds["restic "+fps["restic"]] = &builds.BuilderBuild{State: builds.BuildBuilding}
|
||||
} else if row.placed {
|
||||
// held: its build not done while it runs
|
||||
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &builds.BuilderBuild{State: builds.BuildBuilding}
|
||||
} else {
|
||||
// taken out of the node's assignment while what runs still names it: the account goes with it, never kept stale
|
||||
p := bb.ByNode[Node]
|
||||
p.Modules = slices.DeleteFunc(slices.Clone(p.Modules), func(m meshmoduleassigner.PlacedModule) bool { return m.Module == "mesh-provisioner-client" })
|
||||
bb.ByNode[Node] = p
|
||||
want = ""
|
||||
}
|
||||
after, _, held, err := frombus.Compose(context.Background(), bb, frombus.Mesh{}, frombus.Scope{Node: Node, Running: runs}, fps)
|
||||
if _, isHeld := held["mesh-provisioner-client"]; err != nil || isHeld != (row.placed && !row.userless && !row.filledHeld) {
|
||||
t.Fatalf("%+v: held %v: %v", row, held, err)
|
||||
}
|
||||
if got := after[ownKey].Own.Provisioner; got != want {
|
||||
t.Errorf("%+v: the node's own names %q, want %q", row, got, want)
|
||||
}
|
||||
if _, resticHeld := held["restic"]; row.filledHeld && !resticHeld {
|
||||
t.Errorf("%+v: restic held %v", row, resticHeld)
|
||||
}
|
||||
if row.placed && !row.userless && !row.filledHeld && !reflect.DeepEqual(after[resticKey].Filled, before[resticKey].Filled) {
|
||||
t.Errorf("%+v: restic's filled files %v, want %v", row, after[resticKey].Filled, before[resticKey].Filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// statesOf is the bus b as a module's read states hold it.
|
||||
func statesOf(t *testing.T, b *Bus) frombus.States {
|
||||
t.Helper()
|
||||
s := states{}
|
||||
put := func(state, key string, raw []byte, err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s[state] == nil {
|
||||
s[state] = map[string][]byte{}
|
||||
}
|
||||
s[state][key] = raw
|
||||
}
|
||||
for _, p := range b.ByNode {
|
||||
vals, err := meshmoduleassignerclient.AssignmentsValues(p)
|
||||
if err != nil {
|
||||
put(frombus.AssignmentsRead, p.Node+".node", nil, err)
|
||||
}
|
||||
for key, raw := range vals {
|
||||
put(frombus.AssignmentsRead, key, raw, nil)
|
||||
}
|
||||
}
|
||||
for node, rec := range b.Record {
|
||||
raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec})
|
||||
put(frombus.HaveRead, node+".node", raw, err)
|
||||
}
|
||||
for module, m := range b.Module {
|
||||
key, _ := meshcatalogclient.ModulesKey(module)
|
||||
raw, err := meshcatalogclient.EncodeModule(m)
|
||||
put(frombus.ModulesRead, key, raw, err)
|
||||
}
|
||||
for digest, man := range b.Manifests {
|
||||
key, _ := meshcatalogclient.ManifestsKey(digest)
|
||||
raw, err := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "m", Manifest: string(man)})
|
||||
put(frombus.ManifestsRead, key, raw, err)
|
||||
}
|
||||
for at, build := range b.Builds {
|
||||
module, fp, _ := strings.Cut(at, " ")
|
||||
key, _ := builds.BuilderBuildKey(module, fp)
|
||||
raw, err := builds.EncodeBuilderBuild(*build)
|
||||
put(frombus.BuildsRead, key, raw, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// states are read states in memory.
|
||||
type states map[string]map[string][]byte
|
||||
|
||||
func (s states) Get(state, key string) ([]byte, bool, error) {
|
||||
v, ok := s[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (s states) Keys(state string) ([]string, error) {
|
||||
var keys []string
|
||||
for k := range s[state] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok }
|
||||
|
||||
// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item
|
||||
// with attributes its bar places by none of.
|
||||
const unusableItem = `{"module":"backup","version":"1","moved":true,` +
|
||||
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
|
||||
`"items":[{"collection":"contribution:node-bar:block",` +
|
||||
`"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}`
|
||||
@@ -0,0 +1,10 @@
|
||||
package frombustest
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
|
||||
)
|
||||
|
||||
// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus
|
||||
// (novox/hq ADR 0635 §1); catalogue compiles none.
|
||||
func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) }
|
||||
internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"module": "mesh-provisioner-client",
|
||||
"version": "1",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-provisioner",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"provisioner-broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-provisioner-client",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-provisioner-client",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-provisioner-client",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "client",
|
||||
"type": "process",
|
||||
"name": "mesh-provisioner-client",
|
||||
"artifact": "client",
|
||||
"run": [
|
||||
"./mesh-provisioner-client"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
|
||||
"MESH_PROVISIONER_STATE": "${dir:state}"
|
||||
},
|
||||
"user": "mesh-provisioner-client",
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "client",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-provisioner-client",
|
||||
"binary": "mesh-provisioner-client"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
|
||||
${backup:lines}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user