Test the Node build module's fetch, forge files, manifest and guard (issues 1489, 1484, 1488), part 19 of 19
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
const commitSHA = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
var fetchArgs = map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}
|
||||
|
||||
// fetchMachine is a machine whose git checks out the given files into the tree it is asked to fetch into (the fake
|
||||
// answers the commands of FetchCommit and `git ls-files`), and whose npm ci writes the packages the test names.
|
||||
func fetchMachine(t *testing.T, files map[string]string, installs map[string]string) (*Module, *fakeMachine) {
|
||||
t.Helper()
|
||||
m, f, _ := newMachine(t)
|
||||
f.answer["git rev-parse"] = forgekey.Ran{Stdout: commitSHA + "\n"}
|
||||
var manifests []string
|
||||
for p := range files {
|
||||
if strings.HasSuffix(p, "module.json") {
|
||||
manifests = append(manifests, p)
|
||||
}
|
||||
}
|
||||
f.answer["git ls-files"] = forgekey.Ran{Stdout: strings.Join(manifests, "\n") + "\n"}
|
||||
f.effect = func(c call) {
|
||||
switch {
|
||||
case c.name == "git" && slices_contains(c.args, "checkout"):
|
||||
for i, a := range c.args {
|
||||
if a == "-C" {
|
||||
for p, body := range files {
|
||||
write(t, filepath.Join(c.args[i+1], filepath.FromSlash(p)), body, 0o644)
|
||||
}
|
||||
}
|
||||
}
|
||||
case c.name == "npm":
|
||||
for i, a := range c.args {
|
||||
if a == "--prefix" {
|
||||
for p, body := range installs {
|
||||
write(t, filepath.Join(c.args[i+1], "node_modules", filepath.FromSlash(p)), body, 0o644)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, f
|
||||
}
|
||||
|
||||
func slices_contains(l []string, s string) bool {
|
||||
for _, x := range l {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func typeScriptModule(pkg string) map[string]string {
|
||||
return map[string]string{
|
||||
"modules/m/module.json": `{"module":"m","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`,
|
||||
"modules/m/package.json": pkg,
|
||||
}
|
||||
}
|
||||
|
||||
func npmCalls(f *fakeMachine) []call {
|
||||
var out []call
|
||||
for _, c := range f.calls {
|
||||
if c.name == "npm" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1489/AC1: a module whose package.json names only @novox/mesh-sdk and @types/node (and typescript) installs nothing
|
||||
// and the step passes.
|
||||
func TestAModuleNamingOnlyTheSdkAndTypesInstallsNothing(t *testing.T) {
|
||||
m, f := fetchMachine(t, typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6"},"devDependencies":{"@types/node":"^22","typescript":"^5"}}`), nil)
|
||||
out, err := m.fetch(context.Background(), fetchArgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := out.(FetchResult)
|
||||
if len(npmCalls(f)) != 0 || len(r.Installed) != 0 || len(r.Modules) != 1 || r.Modules[0] != "modules/m" || r.Commit != commitSHA {
|
||||
t.Errorf("result = %+v, npm calls %d, want the module found and nothing installed", r, len(npmCalls(f)))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(m.Work, r.Workspace, "tree", "modules", "m", "module.json")); err != nil {
|
||||
t.Errorf("the commit is not checked out in the workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1489/AC2: a module naming lodash with a lock gets npm ci --omit=dev --ignore-scripts (fake runner), its install put
|
||||
// at its node_modules without the SDK and without any copy of it; without a lock the step fails naming the directory.
|
||||
func TestAModuleNamingLodashWithALockIsInstalledWithoutTheSdkAndWithoutALockFails(t *testing.T) {
|
||||
files := typeScriptModule(`{"dependencies":{"@novox/mesh-sdk":"^0.1.6","lodash":"^4"},"devDependencies":{"x":"1"},"scripts":{"test":"y"}}`)
|
||||
files["modules/m/package-lock.json"] = `{"lockfileVersion":3}`
|
||||
m, f := fetchMachine(t, files, map[string]string{"lodash/index.js": "l", "@novox/mesh-sdk/index.js": "sdk", "a/node_modules/@novox/mesh-sdk/i.js": "nested"})
|
||||
out, err := m.fetch(context.Background(), fetchArgs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := out.(FetchResult)
|
||||
calls := npmCalls(f)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("npm ran %d times, want once", len(calls))
|
||||
}
|
||||
line := strings.Join(calls[0].args, " ")
|
||||
if !strings.HasPrefix(line, "ci --omit=dev --omit=peer --ignore-scripts --no-bin-links") {
|
||||
t.Errorf("ran npm %s", line)
|
||||
}
|
||||
nm := filepath.Join(m.Work, r.Workspace, "tree", "modules", "m", "node_modules")
|
||||
if _, err := os.Stat(filepath.Join(nm, "lodash", "index.js")); err != nil {
|
||||
t.Errorf("lodash is not at the module's node_modules: %v", err)
|
||||
}
|
||||
for _, gone := range []string{"@novox/mesh-sdk", "a/node_modules/@novox/mesh-sdk"} {
|
||||
if _, err := os.Stat(filepath.Join(nm, gone)); err == nil {
|
||||
t.Errorf("a copy of the SDK is left at %s", gone)
|
||||
}
|
||||
}
|
||||
if got := strings.Join(r.Installed, ","); got != "modules/m" {
|
||||
t.Errorf("installed = %q", got)
|
||||
}
|
||||
// the module's own package.json is untouched: the install worked on a scratch copy.
|
||||
if raw, _ := os.ReadFile(filepath.Join(m.Work, r.Workspace, "tree", "modules", "m", "package.json")); !strings.Contains(string(raw), "scripts") {
|
||||
t.Errorf("the module's package.json was rewritten: %s", raw)
|
||||
}
|
||||
var scratch []string
|
||||
for _, e := range mustReadDir(t, filepath.Join(m.Work, r.Workspace)) {
|
||||
scratch = append(scratch, e)
|
||||
}
|
||||
if strings.Contains(strings.Join(scratch, ","), "npm-") {
|
||||
t.Errorf("the scratch install is left in the workspace: %v", scratch)
|
||||
}
|
||||
|
||||
delete(files, "modules/m/package-lock.json")
|
||||
m, _ = fetchMachine(t, files, nil)
|
||||
if _, err := m.fetch(context.Background(), fetchArgs); err == nil || !strings.Contains(err.Error(), "modules/m") || !strings.Contains(err.Error(), "package-lock.json") {
|
||||
t.Errorf("without a lock: %v, want the failure naming modules/m", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustReadDir(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
es, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for _, e := range es {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1489/AC3: given a process environment holding a token, no command of the step sees it. The commands the step makes
|
||||
// are given PATH, HOME and the locale and their own words; and the real clean runner, run on `env` here, prints only
|
||||
// those.
|
||||
func TestNoCommandOfTheStepSeesATokenOfTheProcess(t *testing.T) {
|
||||
t.Setenv("MESH_BUS_CREDENTIAL", "hunter2")
|
||||
t.Setenv("NPM_TOKEN", "hunter3")
|
||||
files := typeScriptModule(`{"dependencies":{"lodash":"^4"}}`)
|
||||
files["modules/m/package-lock.json"] = `{}`
|
||||
m, f := fetchMachine(t, files, map[string]string{"lodash/index.js": "l"})
|
||||
if _, err := m.fetch(context.Background(), fetchArgs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.calls) < 6 {
|
||||
t.Fatalf("only %d commands ran", len(f.calls))
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
for _, e := range c.env {
|
||||
if strings.Contains(e, "hunter") || strings.HasPrefix(e, "MESH_") || strings.HasPrefix(e, "NPM_TOKEN") {
|
||||
t.Errorf("%s %v was given %s", c.name, c.args[:min(len(c.args), 3)], e)
|
||||
}
|
||||
}
|
||||
if len(c.env) < 3 || !strings.HasPrefix(c.env[0], "PATH=") || !strings.HasPrefix(c.env[1], "HOME=") || c.env[2] != "LC_ALL=C" {
|
||||
t.Errorf("%s was given %v, want PATH, HOME and the locale first", c.name, c.env)
|
||||
}
|
||||
}
|
||||
// The runner main wires: what it hands a command is exactly what the step gave.
|
||||
ran := forgekey.ExecRunnerClean(10*time.Second)(context.Background(), append(m.baseEnv(), "ONLY=this"), "env")
|
||||
if ran.Status != 0 || strings.Contains(ran.Stdout, "hunter") || !strings.Contains(ran.Stdout, "ONLY=this") {
|
||||
t.Errorf("the clean runner printed %q", ran.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRefusesWhatIsNotARepositoryBeforeAnythingRuns(t *testing.T) {
|
||||
m, f := fetchMachine(t, nil, nil)
|
||||
for _, repo := range []string{"", "x", "../etc/passwd", "a/b/c", "a/..", "o/r;rm"} {
|
||||
if _, err := m.fetch(context.Background(), map[string]any{"repository": repo, "commit": commitSHA}); err == nil {
|
||||
t.Errorf("%q was accepted", repo)
|
||||
}
|
||||
}
|
||||
if len(f.calls) != 0 {
|
||||
t.Errorf("%d commands ran", len(f.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAsksGitForTheSshRemote(t *testing.T) {
|
||||
m, f := fetchMachine(t, typeScriptModule(`{}`), nil)
|
||||
if _, err := m.fetch(context.Background(), fetchArgs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var all []string
|
||||
for _, c := range f.calls {
|
||||
all = append(all, c.name+" "+strings.Join(c.args, " "))
|
||||
}
|
||||
lines := strings.Join(all, "\n")
|
||||
for _, want := range []string{"remote add origin ssh://mesh-forge/novox/mesh-catalog.git", "fetch --no-tags --depth 1 origin " + commitSHA, "checkout -q --detach " + commitSHA} {
|
||||
if !strings.Contains(lines, want) {
|
||||
t.Errorf("git was not asked %q:\n%s", want, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspacesOlderThanADayAreRemoved(t *testing.T) {
|
||||
m, _, work := newMachine(t)
|
||||
old, fresh := filepath.Join(work, "aaaa"), filepath.Join(work, "bbbb")
|
||||
for _, d := range []string{old, fresh, filepath.Join(work, "Not-A-Workspace")} {
|
||||
os.MkdirAll(d, 0o755)
|
||||
}
|
||||
os.Chtimes(old, time.Now().Add(-25*time.Hour), time.Now().Add(-25*time.Hour))
|
||||
os.Chtimes(filepath.Join(work, "Not-A-Workspace"), time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour))
|
||||
m.CleanWorkspaces(time.Now())
|
||||
if _, err := os.Stat(old); err == nil {
|
||||
t.Errorf("the old workspace is kept")
|
||||
}
|
||||
if _, err := os.Stat(fresh); err != nil {
|
||||
t.Errorf("the fresh workspace was removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(work, "Not-A-Workspace")); err != nil {
|
||||
t.Errorf("a directory that is no workspace was removed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package main
|
||||
|
||||
// The forge side of a build module (novox/hq ADR 0466 §2 and §4, issue 1484). This file is the same for every build
|
||||
// module: mesh-build-node takes a copy and changes the three constants below.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
const (
|
||||
forgeModule = "mesh-build-node"
|
||||
forgeHome = "/var/lib/mesh-build-node"
|
||||
forgeModuleD = "../.." // the module's directory, where module.json and files/ are
|
||||
)
|
||||
|
||||
type forgeManifest struct {
|
||||
Requires []any `json:"requires"`
|
||||
State []any `json:"state"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readForgeManifest(t *testing.T) forgeManifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(forgeModuleD, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m forgeManifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func forgeTemplate(t *testing.T, r map[string]any) string {
|
||||
t.Helper()
|
||||
name, _ := r["template"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("resource %v names no template", r["id"])
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(forgeModuleD, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func forgeResource(t *testing.T, m forgeManifest, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
if r["owner"] != forgeModule {
|
||||
t.Errorf("%s is owned by %v, not the module's own account", id, r["owner"])
|
||||
}
|
||||
if p, _ := r["path"].(string); !strings.HasPrefix(p, "${dir:") || strings.Contains(p, "account-home") {
|
||||
t.Errorf("%s is at %q: a path of the account's own home (${dir:...}), never the operator's", id, p)
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
var forgeSample = map[string]string{
|
||||
"known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3Nza",
|
||||
"host_key": "ssh-ed25519 AAAAC3Nza",
|
||||
"ssh_host": "novox.internal",
|
||||
"ssh_alias": "mesh-forge",
|
||||
"ssh_port": "222",
|
||||
"ssh_base": "ssh://git@novox.internal:222/",
|
||||
"https_base": "https://git.novox.be/",
|
||||
}
|
||||
|
||||
// forgeRender fills ${bound:forge-account:<key>} with the sample values, as the provisioner client does.
|
||||
func forgeRender(t *testing.T, s string) string {
|
||||
t.Helper()
|
||||
for k, v := range forgeSample {
|
||||
s = strings.ReplaceAll(s, "${bound:forge-account:"+k+"}", v)
|
||||
}
|
||||
if strings.Contains(s, "${") {
|
||||
t.Errorf("unfilled placeholder in %q", s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 1484/AC1: given sample bound values, the ssh config has Host mesh-forge, IdentitiesOnly yes and the key under the
|
||||
// module's own home; known_hosts carries the host key; git's config rewrites https_base to ssh://mesh-forge/; no
|
||||
// ${ is left.
|
||||
func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) {
|
||||
m := readForgeManifest(t)
|
||||
|
||||
cfg := forgeResource(t, m, "ssh-config")
|
||||
want := "Host mesh-forge\n HostName novox.internal\n Port 222\n HostKeyAlias mesh-forge\n User git\n IdentityFile ~/.ssh/mesh_ed25519\n IdentitiesOnly yes\n"
|
||||
if got := forgeRender(t, forgeTemplate(t, cfg)); got != want {
|
||||
t.Errorf("ssh config = %q, want %q", got, want)
|
||||
}
|
||||
if cfg["path"] != "${dir:ssh-dir}/config" || cfg["mode"] != "0600" || cfg["into"] != nil {
|
||||
t.Errorf("ssh config resource = %v, want the whole file ${dir:ssh-dir}/config at mode 0600", cfg)
|
||||
}
|
||||
|
||||
kh := forgeResource(t, m, "known-hosts")
|
||||
if got := strings.TrimSpace(forgeRender(t, forgeTemplate(t, kh))); got != "mesh-forge ssh-ed25519 AAAAC3Nza" {
|
||||
t.Errorf("known_hosts = %q", got)
|
||||
}
|
||||
|
||||
g := forgeResource(t, m, "git-config")
|
||||
wantGit := "[url \"ssh://mesh-forge/\"]\n\tinsteadOf = https://git.novox.be/\n"
|
||||
if got := forgeRender(t, forgeTemplate(t, g)); got != wantGit {
|
||||
t.Errorf("git config = %q, want %q", got, wantGit)
|
||||
}
|
||||
|
||||
dir := forgeResource(t, m, "ssh-dir")
|
||||
if dir["mode"] != "0700" || dir["path"] != "${dir:state}/.ssh" {
|
||||
t.Errorf("ssh-dir = %v, want ${dir:state}/.ssh at mode 0700", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// 1484/AC2: the module requires forge-account, keeps the state keys, and every file resource takes its text from a
|
||||
// template in the files/ archive (novox/hq ADR 0349 §2).
|
||||
func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) {
|
||||
m := readForgeManifest(t)
|
||||
if !slices.Contains(m.Requires, any("forge-account")) {
|
||||
t.Errorf("requires = %v, want forge-account among them", m.Requires)
|
||||
}
|
||||
keeps := false
|
||||
for _, s := range m.State {
|
||||
if s == "keys" {
|
||||
keeps = true
|
||||
}
|
||||
}
|
||||
if !keeps {
|
||||
t.Errorf("state = %v lacks keys", m.State)
|
||||
}
|
||||
files := 0
|
||||
for _, r := range m.Resources {
|
||||
if _, inline := r["content"]; inline {
|
||||
t.Errorf("resource %v keeps its text inline", r["id"])
|
||||
}
|
||||
if r["type"] == "file" {
|
||||
files++
|
||||
forgeTemplate(t, r)
|
||||
}
|
||||
}
|
||||
if files == 0 {
|
||||
t.Errorf("no file resource: the forge's ssh files are missing")
|
||||
}
|
||||
packs := 0
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "archive" && a["from"] == "files" && a["name"] == "files" {
|
||||
packs++
|
||||
}
|
||||
}
|
||||
if packs != 1 {
|
||||
t.Errorf("the build packs files/ into %d archives named files, not one", packs)
|
||||
}
|
||||
}
|
||||
|
||||
// forgeEd25519Line is a public key line with a well-formed ed25519 blob.
|
||||
func forgeEd25519Line(comment string) string {
|
||||
blob := []byte{}
|
||||
for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} {
|
||||
l := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(l, uint32(len(part)))
|
||||
blob = append(append(blob, l...), part...)
|
||||
}
|
||||
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment
|
||||
}
|
||||
|
||||
type forgeKeys struct{ puts map[string][]byte }
|
||||
|
||||
func (f *forgeKeys) Put(key string, value any) (uint64, error) {
|
||||
raw, _ := json.Marshal(value)
|
||||
if f.puts == nil {
|
||||
f.puts = map[string][]byte{}
|
||||
}
|
||||
f.puts[key] = raw
|
||||
return uint64(len(f.puts)), nil
|
||||
}
|
||||
|
||||
// forgeKeygen stands in for ssh-keygen: it writes the pair the command names.
|
||||
func forgeKeygen(_ context.Context, _ []string, name string, args ...string) forgekey.Ran {
|
||||
var file, comment string
|
||||
for i, a := range args {
|
||||
switch a {
|
||||
case "-f":
|
||||
file = args[i+1]
|
||||
case "-C":
|
||||
comment = args[i+1]
|
||||
}
|
||||
}
|
||||
if name != "ssh-keygen" || file == "" {
|
||||
return forgekey.Ran{Status: 1, Stderr: "unexpected"}
|
||||
}
|
||||
os.WriteFile(file, []byte("PRIVATE\n"), 0o600)
|
||||
os.WriteFile(file+".pub", []byte(forgeEd25519Line(comment)+"\n"), 0o644)
|
||||
return forgekey.Ran{}
|
||||
}
|
||||
|
||||
// 1484/AC3: given the bundle started with an empty home, the keeper writes <node>.<module> with kind builder, and
|
||||
// the key is in the home's .ssh.
|
||||
func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) {
|
||||
home, st := t.TempDir(), &forgeKeys{}
|
||||
if err := keeperFor(home, "ace", forgeModule, forgeKeygen, st).Once(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".ssh", "mesh_ed25519")); err != nil {
|
||||
t.Errorf("no key in an empty home: %v", err)
|
||||
}
|
||||
raw, ok := st.puts["ace."+forgeModule]
|
||||
if !ok || len(st.puts) != 1 {
|
||||
t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule)
|
||||
}
|
||||
var e forgekey.KeyEntry
|
||||
if err := json.Unmarshal(raw, &e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Kind != "builder" || e.Node != "ace" || e.Account != forgeModule || !strings.HasPrefix(e.Public, "ssh-ed25519 ") {
|
||||
t.Errorf("entry = %s", raw)
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
|
||||
t.Errorf("the entry carries private material: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The home the manifest places is the one the account is given, so the keeper's home and the templates' agree.
|
||||
func TestTheAccountsHomeIsTheOneTheManifestNames(t *testing.T) {
|
||||
m := readForgeManifest(t)
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "user" && r["name"] == forgeModule && r["home"] != forgeHome {
|
||||
t.Errorf("the account's home is %v, want %s", r["home"], forgeHome)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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{"git", "nodejs", "npm", "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, no seat is claimed (ADR 0469 §6), and the health is the module's own
|
||||
// tool, which the manifest also lists among its tools.
|
||||
func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
states := []string{}
|
||||
for _, s := range m.State {
|
||||
states = append(states, s.Name)
|
||||
}
|
||||
slices.Sort(states)
|
||||
if want := []string{"keys", "steps", "toolchains"}; !slices.Equal(states, want) {
|
||||
t.Errorf("state = %v, want %v", states, want)
|
||||
}
|
||||
if len(m.Claims) != 0 {
|
||||
t.Errorf("claims = %v, want no seat", m.Claims)
|
||||
}
|
||||
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 has the mesh write both
|
||||
// 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, "files", "toolchain", name)); err != nil {
|
||||
t.Errorf("the module carries no files/toolchain/%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
lock := resource(t, m, "toolchain-lock")
|
||||
if lock["path"] != "${dir:toolchain-dir}/package-lock.json" || lock["owner"] != "mesh-build-node" {
|
||||
t.Errorf("toolchain-lock = %v", lock)
|
||||
}
|
||||
for _, r := range readForgeManifest(t).Resources {
|
||||
if r["id"] == "toolchain-lock" && r["template"] != "files/toolchain/package-lock.json" {
|
||||
t.Errorf("toolchain-lock takes its text from %v", r["template"])
|
||||
}
|
||||
}
|
||||
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, "files", "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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// keptStates stands in for the toolchains and the steps states.
|
||||
type keptStates struct {
|
||||
mu sync.Mutex
|
||||
toolchains map[string]delivery.Toolchain
|
||||
steps map[string][]delivery.Step
|
||||
ended chan string
|
||||
}
|
||||
|
||||
func newStates() *keptStates {
|
||||
return &keptStates{toolchains: map[string]delivery.Toolchain{}, steps: map[string][]delivery.Step{}, ended: make(chan string, 16)}
|
||||
}
|
||||
|
||||
func (s *keptStates) putToolchain(key string, value any) (uint64, error) {
|
||||
tc, err := delivery.DecodeToolchain(value.(json.RawMessage))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.toolchains[key] = tc
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (s *keptStates) putStep(key string, value any) (uint64, error) {
|
||||
st, err := delivery.DecodeStep(value.(json.RawMessage))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.steps[key] = append(s.steps[key], st)
|
||||
s.mu.Unlock()
|
||||
if st.State != delivery.StepRunning {
|
||||
s.ended <- key
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (s *keptStates) toolchain(key string) (delivery.Toolchain, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tc, ok := s.toolchains[key]
|
||||
return tc, ok
|
||||
}
|
||||
|
||||
func (s *keptStates) waitEnd(t *testing.T) delivery.Step {
|
||||
t.Helper()
|
||||
select {
|
||||
case key := <-s.ended:
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v := s.steps[key]
|
||||
return v[len(v)-1]
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("the step never ended on its key")
|
||||
return delivery.Step{}
|
||||
}
|
||||
}
|
||||
|
||||
func moduleWith(states *keptStates, installed string) *Module {
|
||||
return &Module{Node: "ace", Recipe: Recipe{Steps: "1", Node: "v26.10.0", Lock: "lock"},
|
||||
Probe: func(context.Context) (string, error) { return installed, nil },
|
||||
Toolchains: states.putToolchain, Steps: states.putStep, Ctx: context.Background()}
|
||||
}
|
||||
|
||||
// 1488/AC3: a probe answering v26.10.0: the bundle's start writes ace.typescript with that version and the recipe's,
|
||||
// and the health tool answers healthy.
|
||||
func TestStartWritesTheToolchainAndHealthIsHealthy(t *testing.T) {
|
||||
states := newStates()
|
||||
m := moduleWith(states, "v26.10.0")
|
||||
ctx, stop := context.WithCancel(context.Background())
|
||||
defer stop()
|
||||
go m.Start(ctx)
|
||||
var tc delivery.Toolchain
|
||||
for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
|
||||
if got, ok := states.toolchain("ace.typescript"); ok {
|
||||
tc = got
|
||||
break
|
||||
}
|
||||
}
|
||||
if tc.Version != "v26.10.0" || tc.Recipe != m.Recipe.Version() || tc.Language != "typescript" {
|
||||
t.Fatalf("ace.typescript = %+v, want v26.10.0 and recipe %s", tc, m.Recipe.Version())
|
||||
}
|
||||
if a := m.Health(ctx); a.State != health.StateHealthy || !a.Healthy {
|
||||
t.Errorf("health = %+v, want healthy", a)
|
||||
}
|
||||
}
|
||||
|
||||
// 1488/AC3: a probe answering v26.11.0: every step tool's step ends refused naming both versions, ace.typescript
|
||||
// holds v26.11.0, and the health tool answers unhealthy naming both.
|
||||
func TestAnotherNodeRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
|
||||
states := newStates()
|
||||
m := moduleWith(states, "v26.11.0")
|
||||
ran := false
|
||||
run := func(context.Context, map[string]any) (any, error) { ran = true; return "done", nil }
|
||||
steps := append([]StepTool{
|
||||
{Name: "long_probe", Long: true, Run: run},
|
||||
{Name: "short_probe", Run: run},
|
||||
}, (&Module{}).StepTools()...)
|
||||
for _, tool := range m.Tools(steps) {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
out, err := tool.Run(map[string]any{})
|
||||
if isLong(steps, tool.Name) {
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tool.Name, err)
|
||||
}
|
||||
if out.(map[string]any)["step"] == "" {
|
||||
t.Fatalf("%s answered no step id: %v", tool.Name, out)
|
||||
}
|
||||
st := states.waitEnd(t)
|
||||
if st.State != delivery.StepRefused {
|
||||
t.Errorf("%s: the step ended %s, want refused", tool.Name, st.State)
|
||||
}
|
||||
reason = st.Reason
|
||||
} else {
|
||||
if err == nil {
|
||||
t.Fatalf("%s: answered %v, want a refusal", tool.Name, out)
|
||||
}
|
||||
reason = err.Error()
|
||||
}
|
||||
if !strings.Contains(reason, "v26.11.0") || !strings.Contains(reason, "v26.10.0") {
|
||||
t.Errorf("%s: the refusal %q does not name both versions", tool.Name, reason)
|
||||
}
|
||||
}
|
||||
if ran {
|
||||
t.Errorf("a step ran on a node that refuses")
|
||||
}
|
||||
if tc, _ := states.toolchain("ace.typescript"); tc.Version != "v26.11.0" {
|
||||
t.Errorf("ace.typescript holds %q, want the installed v26.11.0", tc.Version)
|
||||
}
|
||||
a := m.Health(context.Background())
|
||||
if a.State != health.StateUnhealthy || !strings.Contains(a.Why, "v26.11.0") || !strings.Contains(a.Why, "v26.10.0") {
|
||||
t.Errorf("health = %+v, want unhealthy naming both versions", a)
|
||||
}
|
||||
}
|
||||
|
||||
func isLong(steps []StepTool, name string) bool {
|
||||
for _, s := range steps {
|
||||
if s.Name == name {
|
||||
return s.Long
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 1488/AC2: the recipe version moves with one byte of the lock and with the Node version, and with the recipe's own
|
||||
// version; equal recipes have the same one.
|
||||
func TestRecipeVersionMovesWithEachOfItsValues(t *testing.T) {
|
||||
lock := []byte(`{"lockfileVersion":3}`)
|
||||
base := Recipe{Steps: "1", Node: "v26.10.0", Lock: LockHash(lock)}
|
||||
if base.Version() != (Recipe{Steps: "1", Node: "v26.10.0", Lock: LockHash(lock)}).Version() {
|
||||
t.Errorf("one recipe has two versions")
|
||||
}
|
||||
seen := map[string]string{base.Version(): "base"}
|
||||
for name, r := range map[string]Recipe{
|
||||
"one byte of the lock": {Steps: "1", Node: "v26.10.0", Lock: LockHash([]byte(`{"lockfileVersion":4}`))},
|
||||
"node": {Steps: "1", Node: "v26.11.0", Lock: LockHash(lock)},
|
||||
"steps": {Steps: "2", Node: "v26.10.0", Lock: LockHash(lock)},
|
||||
} {
|
||||
v := r.Version()
|
||||
if was, dup := seen[v]; dup {
|
||||
t.Errorf("the recipe differing in %s has the version of the %s recipe: %s", name, was, v)
|
||||
}
|
||||
seen[v] = name
|
||||
}
|
||||
}
|
||||
|
||||
// 1488/AC2: the recipe reads the lock file's bytes: a lock changed on disk is another recipe version, a lock missing
|
||||
// is an error.
|
||||
func TestReadRecipeHashesTheLockFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "package-lock.json")
|
||||
if _, err := ReadRecipe(path); err == nil {
|
||||
t.Errorf("a missing lock was accepted")
|
||||
}
|
||||
os.WriteFile(path, []byte("a"), 0o600)
|
||||
a, err := ReadRecipe(path)
|
||||
if err != nil || a.Node != "v26.10.0" {
|
||||
t.Fatalf("= %+v, %v", a, err)
|
||||
}
|
||||
os.WriteFile(path, []byte("b"), 0o600)
|
||||
b, _ := ReadRecipe(path)
|
||||
if a.Version() == b.Version() {
|
||||
t.Errorf("one byte of the lock did not move the recipe version")
|
||||
}
|
||||
}
|
||||
|
||||
// 1488/AC3: a lock changed under a running module is read at the next check, and a lock that cannot be read refuses.
|
||||
func TestACheckReadsTheRecipeAgain(t *testing.T) {
|
||||
states := newStates()
|
||||
m := moduleWith(states, "v26.10.0")
|
||||
r2 := Recipe{Steps: "1", Node: "v26.10.0", Lock: "other"}
|
||||
m.Reload = func() (Recipe, error) { return r2, nil }
|
||||
if err := m.Check(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc, _ := states.toolchain("ace.typescript"); tc.Recipe != r2.Version() {
|
||||
t.Errorf("ace.typescript holds recipe %s, want the reloaded %s", tc.Recipe, r2.Version())
|
||||
}
|
||||
m.Reload = func() (Recipe, error) { return Recipe{}, errors.New("no lock") }
|
||||
if err := m.Check(context.Background()); err == nil || m.Health(context.Background()).Healthy {
|
||||
t.Errorf("a lock that cannot be read neither refused nor made the module unhealthy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNodeVersion(t *testing.T) {
|
||||
if v, err := parseNodeVersion("v26.10.0\n"); err != nil || v != "v26.10.0" {
|
||||
t.Errorf("= %q, %v", v, err)
|
||||
}
|
||||
if _, err := parseNodeVersion("not node"); err == nil {
|
||||
t.Errorf("accepted a text that is not `node --version`'s")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user