Files
mesh-catalog/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go
T
jschoubben 78d26867d2 Give the build modules' steps state an age of two days, and keep a node's pause in a pauses state of its own
mesh-build-go's steps bucket filled to the bus's 64 MiB cap (27,370 keys, none ever removed) and
refused the end of a passed check, so the check read failed; mesh-build-node's (17.8 MB) and
mesh-build-image's grow the same way. A step's key is per step: its reader reads its end within
the step's bound, and a check's verdict is kept by the checker, so two days is ample. The node's
pause key lived in the same bucket and would have ended by itself; it moves to a pauses state
without an age (mesh-sdk fix/steps-bucket-ages, Puller.PausePut). No node is paused today, so
nothing is carried over.
2026-10-25 18:53:04 +01:00

255 lines
10 KiB
Go

package main
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The catalogue's root and this module's directory.
const (
catalogueRoot = "../../../.."
moduleDir = "../.."
)
func readManifest(t *testing.T) catalogue.Manifest {
t.Helper()
m, err := catalogue.ReadTreeManifest(catalogueRoot, moduleDir)
if err != nil {
t.Fatalf("mesh-sdk's validator refuses module.json: %v", err)
}
return m
}
func resource(t *testing.T, m catalogue.Manifest, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("module.json declares no resource %q", id)
return nil
}
// 1488/AC1: mesh-sdk's validator accepts the module (alone on a shelf too), and it runs as mesh-build-node with its
// home at mode 0700, requires user-namespaces and declares the four packages and carries toolchain/package-lock.json.
func TestManifestIsValidAndDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) {
m := readManifest(t)
if problems := catalogue.CatalogueProblems(catalogue.Shelf{m.Module: m}); len(problems) > 0 {
t.Errorf("the catalogue's rules refuse it: %v", problems)
}
if m.Module != "mesh-build-node" || m.RunsAs != "mesh-build-node" {
t.Errorf("module %q runs as %q, want mesh-build-node as itself", m.Module, m.RunsAs)
}
account := resource(t, m, "account")
if account["type"] != "user" || account["name"] != "mesh-build-node" || account["shell"] != "/usr/bin/nologin" || account["home"] != "/var/lib/mesh-build-node" {
t.Errorf("account = %v", account)
}
home := resource(t, m, "state")
if home["type"] != "directory" || home["mode"] != "0700" || home["owner"] != "mesh-build-node" || home["place"] != "." {
t.Errorf("home = %v, want its directory at mode 0700 owned by the account", home)
}
if !slices.Contains(m.Capabilities, "user-namespaces") {
t.Errorf("capabilities = %v, want user-namespaces", m.Capabilities)
}
packages := []string{}
for _, r := range m.Resources {
if r["type"] == "package" {
packages = append(packages, r["package"].(string))
}
}
slices.Sort(packages)
if want := []string{"bubblewrap", "git", "openssh"}; !slices.Equal(packages, want) {
t.Errorf("packages = %v, want %v", packages, want)
}
}
// The work and cache directories are outside the home (ADR 0466 §1), owned by the account.
func TestWorkAndCacheDirectoriesLieOutsideTheHome(t *testing.T) {
m := readManifest(t)
for _, id := range []string{"work", "cache"} {
r := resource(t, m, id)
path, _ := r["path"].(string)
if path == "" || path == "/var/lib/mesh-build-node" || len(path) > len("/var/lib/mesh-build-node") && path[:len("/var/lib/mesh-build-node/")] == "/var/lib/mesh-build-node/" {
t.Errorf("%s is at %q, inside or at the home", id, path)
}
if r["owner"] != "mesh-build-node" {
t.Errorf("%s is owned by %v", id, r["owner"])
}
}
}
// The states are the three the module keeps, the one seat it claims is its language's build seat (ADR 0525 §2, which
// changes ADR 0469 §6's "no seat"), and the health is the module's own tool, which the manifest also lists among its
// tools.
func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *testing.T) {
m := readManifest(t)
states := []string{}
for _, s := range m.State {
states = append(states, s.Name)
// A step's key is per step and dropped after its age, so the bucket cannot fill; a pause has no age.
if (s.Name == "steps") != (s.TTLSeconds > 0) {
t.Errorf("state %s has ttl-seconds %d: only steps has an age", s.Name, s.TTLSeconds)
}
}
slices.Sort(states)
if want := []string{"keys", "pauses", "steps", "toolchains"}; !slices.Equal(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "pause", "resume"}) {
t.Errorf("claims = %v, want the seat %s alone", m.Claims, Seat)
}
if !slices.Contains(m.Tools, HealthTool) {
t.Errorf("tools = %v lack %s", m.Tools, HealthTool)
}
raw, _ := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if !strings.Contains(string(raw), `"tool": "`+HealthTool+`"`) {
t.Errorf("runtime-health does not name %s", HealthTool)
}
}
// Every tool the manifest names is served, and every tool served is named.
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
m := readManifest(t)
served := []string{}
for _, tool := range (&Module{}).Tools((&Module{}).StepTools()) {
served = append(served, tool.Name)
}
slices.Sort(served)
named := slices.Clone(m.Tools)
slices.Sort(named)
if !slices.Equal(served, named) {
t.Errorf("served %v, manifest names %v", served, named)
}
}
// 1488/AC1: the module carries the committed lock beside its package.json, and the manifest unpacks them into the
// home's toolchain directory, where the program reads them.
func TestTheModuleCarriesItsToolchainLockAndTheManifestPlacesIt(t *testing.T) {
m := readManifest(t)
for _, name := range []string{"package.json", "package-lock.json"} {
if _, err := os.Stat(filepath.Join(moduleDir, "toolchain", name)); err != nil {
t.Errorf("the module carries no toolchain/%s: %v", name, err)
}
}
if !slices.Contains(m.Requires, "node-toolchain") {
t.Errorf("requires = %v lack node-toolchain, which provides the nodejs and npm packages (issue 1573)", m.Requires)
}
if !slices.Contains(m.Requires, "npm-package-registry") {
t.Errorf("requires = %v lack npm-package-registry, which the install resolves @novox from", m.Requires)
}
}
// 1488/AC1: the lock names the versions of the recipe: typescript 5.9.3, esbuild 0.25.12, @novox/mesh-sdk 0.1.14
// and nats, all by the lock's own hash (read from the file the module carries).
func TestTheLockNamesTheToolchainVersions(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "toolchain", "package-lock.json"))
if err != nil {
t.Fatal(err)
}
var lock struct {
Packages map[string]struct {
Version string `json:"version"`
} `json:"packages"`
}
if err := json.Unmarshal(raw, &lock); err != nil {
t.Fatal(err)
}
for pkg, want := range map[string]string{"typescript": "5.9.3", "esbuild": "0.25.12", "@novox/mesh-sdk": "0.1.14", "nats": "2.29.3", "@types/node": "22.20.1"} {
if got := lock.Packages["node_modules/"+pkg].Version; got != want {
t.Errorf("%s = %q in the lock, want %s", pkg, got, want)
}
}
}
// 1488 (the catalogue's manifest check): a manifest names no host, the mesh being name-agnostic. The tree-read
// manifest carries each template's text as the resource's content, and the toolchain's lock names registries, so it is
// shipped as an archive artifact instead, never as a template. No resource of the module, read as the mesh reads it,
// carries content naming a host, and the raw module.json names none either.
func TestNoResourceCarriesContentNamingAHost(t *testing.T) {
host := regexp.MustCompile(`(?i)://[a-z0-9]|\b[a-z0-9-]+(\.[a-z0-9-]+)*\.(com|org|net|be|io|dev)\b`)
m := readManifest(t)
for i, r := range m.Resources {
if c, _ := r["content"].(string); host.MatchString(c) {
t.Errorf("resources[%d] (%v) carries content naming %q", i, r["id"], host.FindString(c))
}
}
raw, _ := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if h := host.FindString(string(raw)); h != "" {
t.Errorf("module.json names %q", h)
}
}
// 1488: the toolchain's package.json and lock are shipped in the archive artifact `toolchain` and unpacked into the
// home's toolchain directory, where the program reads them.
func TestTheToolchainFilesAreAnArchiveUnpackedIntoTheHome(t *testing.T) {
m := readManifest(t)
r := resource(t, m, "toolchain-dir")
if r["type"] != "archive" || r["path"] != "${dir:state}/toolchain" || r["owner"] != "mesh-build-node" {
t.Errorf("toolchain-dir = %v, want an archive unpacked at ${dir:state}/toolchain", r)
}
if _, err := os.Stat(filepath.Join(moduleDir, "toolchain", "package-lock.json")); err != nil {
t.Errorf("the module carries no toolchain/package-lock.json: %v", err)
}
}
// 1309-S1: every run step runs in bubblewrap, so the module declares its package (the lead's decision: each build module
// that runs a step through it declares it), beside the user-namespaces capability bubblewrap needs.
func TestTheModuleDeclaresBubblewrapAndTheCapabilityItNeeds(t *testing.T) {
m := readManifest(t)
if r := resource(t, m, "bubblewrap"); r["type"] != "package" || r["package"] != "bubblewrap" {
t.Errorf("the bubblewrap resource is %v, want the package bubblewrap", r)
}
if !slices.Contains(m.Capabilities, "user-namespaces") {
t.Errorf("capabilities %v lack user-namespaces", m.Capabilities)
}
}
// 1587-S5 (ADR 0498 §2): the module requires the artifact store and is told its address in a file of its home, which no
// run step is given (a run step has no network either, ADR 0448).
func TestTheModuleRequiresTheArtifactStoreAndIsToldItsAddress(t *testing.T) {
m := readManifest(t)
if !slices.Contains(m.Requires, "artifact-store") {
t.Errorf("requires %v lack artifact-store", m.Requires)
}
// The validator reads the template (files/registry) into the resource's content.
r := resource(t, m, "registry")
if content, _ := r["content"].(string); r["type"] != "file" || r["path"] != "${dir:state}/registry" || !strings.Contains(content, "${seat:mesh-artifact-store:reach}") {
t.Errorf("the registry resource is %v", r)
}
}
// 1587-S17 (ADR 0498 §5): the module publishes a trunk package itself, so its contribution to the package registry asks
// for write access; the credential stays with the module's own process (a run step has neither the home nor a network).
func TestTheModuleContributesWriteAccessToThePackageRegistry(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var mj struct {
Contributes map[string]map[string]any `json:"contributes"`
}
if err := json.Unmarshal(raw, &mj); err != nil {
t.Fatal(err)
}
if got := mj.Contributes["npm-package-registry"]["access"]; got != "write" {
t.Errorf("npm-package-registry access = %v, want write", got)
}
}
// 1911: the module pulls its asks through node-tools' seat pull (mesh-tools, R4g), so it requires node-tools on its node
// and the delivery plan puts node-tools first: a node's build module never pulls through a tool runner that cannot.
func TestTheModuleRequiresNodeToolsItPullsThrough(t *testing.T) {
m := readManifest(t)
if !slices.Contains(m.Requires, "node-tools") {
t.Errorf("requires %v lack node-tools", m.Requires)
}
}