343 lines
14 KiB
Go
343 lines
14 KiB
Go
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)
|
|
}
|