Compare commits

..
Author SHA1 Message Date
jschoubben 6c0024e43b Merge pull request 'Make the catalogue's tests pass in the build sandbox, and give the integration level the installed Node toolchain read-only (issue 1891, ADR 0533)' (#459) from fix/logrotate-base-parses into main
Reviewed-on: #459
2026-10-23 09:48:22 +00:00
jschoubben a2e5a20f52 mesh-build-node: write the install's registry credential in the account's home, never beneath the opened cache, and refuse to open a toolchain holding one (issue 1891 review, ADR 0533 §2)
mesh/check checking a2e5a20f52ed on 89e15c7bcda6
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
2026-10-23 11:45:20 +02:00
jschoubben 845479d7cd mesh-build-node: prove the npm download cache beside the toolchains stays its owner's (ADR 0533 §2) 2026-10-23 11:30:26 +02:00
jschoubben 2ebcb18097 realMachine: a change carrying a lock no release has installed runs with the toolchain installed last, and says so, so a lock change is not refused by its own check (issue 1891, ADR 0533 §3) 2026-10-23 11:23:11 +02:00
jschoubben 00e54d46d7 Give the integration level the node's installed Node toolchains read-only, so the Node build module's tests build offline (issue 1891, ADR 0533)
mesh-build-go binds /var/lib/mesh-build-node-cache/toolchain read-only into the
integration level's one shut-off process when it is there; the unit level never.
mesh-build-node opens its installed toolchains to other accounts (folders 0755,
files 0644, writable by its owner alone; its cache 0711, so the npm cache beside
them stays its own), and realMachine copies the toolchain of its own lock from
there instead of npm ci from the registry, which the level cannot reach.
2026-10-23 11:22:05 +02:00
jschoubben 933370df7c gitea, minio: compile the sources beside themselves in the test script, as the old check did (issue 1891)
The resolve hook needed the typescript package, which mesh-build-node keeps
out of the workspace (notCopied): tsc is reached only as the bin/tsc
command. So the test scripts now do what merge-check-typescript.sh did
before the tests: tsc over the module's sources with --outDir ., so the
sources' ./x.js imports resolve. The emitted .js is ignored by git.
2026-10-23 10:22:41 +02:00
jschoubben 437c524b81 gitea, minio, mosquitto: tests run as the Node build module runs them, tsc -p . then npm test (issue 1891)
The old TypeScript check compiled every source beside itself, and into
dist/, before it ran the tests; mesh-build-node runs `tsc -p .` (these
tsconfigs say noEmit) and then the package's test script, so:

- gitea and minio: the tests import the .ts sources, which import each
  other as ./x.js. A resolve hook in test/ resolves such an import, when no
  .js is there, to the .ts beside it and compiles it with the toolchain's
  TypeScript (gitea's sources use parameter properties, which type
  stripping refuses). 57 of gitea's tests and minio's links test failed.
- mosquitto: its ctrl test imports ../dist/client.js; its test script now
  builds first, as gitea's does.

Test scripts and a test helper only; what is built and delivered is the same.
2026-10-23 10:18:57 +02:00
jschoubben dc96d7c2dd gitea: take a rule's null contexts as the forge's type says, so the strict build the checker runs compiles (issue 1891)
The checker builds a package with a build script by its tsconfig (strict),
where requiredOf and TrunkForge's appliedProtection said
status_check_contexts is string[] and BranchProtection says string[] | null:
four TS2345 errors, hidden on main behind earlier failures. Types only; the
emitted code is the same, and a test holds that null contexts require nothing.
2026-10-23 10:00:09 +02:00
jschoubben f9247342d1 pacman, ssh-client: tests read nothing of the machine, so the unit level passes shut off and on a desk (issue 1891)
pacman-conf proved the shipped pacman.conf through its Include of
/etc/pacman.d/mirrorlist, which the shut-off unit level does not have: the
proven file now reads a mirror list of its own, and a guard test holds that
every repository of the shipped file includes the list reflector writes.

ssh-client's Test case for a run without an agent read the account's
SSH_AUTH_SOCK, so it failed on any desk with an agent: it now sets its own,
and also proves the environment's agent is the one reported.
2026-10-23 09:38:40 +02:00
jschoubben b1d875410a logrotate: dry-run the base against logs of its own, so the shut-off unit level does not stat the machine's /var/log (issue 1891)
The unit level runs in bubblewrap, where /var/log is not there; the base's
/var/log/wtmp block has no missingok, so logrotate -d failed its stat and
every mesh-catalog check that reached this test failed.
2026-10-23 09:10:09 +02:00
49 changed files with 658 additions and 742 deletions
+2
View File
@@ -0,0 +1,2 @@
# The test script compiles the sources beside themselves before the tests import them (package.json, issue 1891).
*.js
+1 -1
View File
@@ -6,7 +6,7 @@
"private": true,
"scripts": {
"build": "tsc client.ts token.ts index.ts provisioner/index.ts provisioner/teams.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
"test": "npm run build && tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
+1 -1
View File
@@ -25,7 +25,7 @@ export const CHECK_STATUS = "mesh/check";
/** The statuses a branch's rule requires, from the rule the forge applies to it: none when it is unprotected or checks
* no status; undefined when the rule could not be read. */
export function requiredOf(rule: { enable_status_check?: boolean; status_check_contexts?: string[] } | null | undefined): string[] | undefined {
export function requiredOf(rule: { enable_status_check?: boolean; status_check_contexts?: string[] | null } | null | undefined): string[] | undefined {
if (rule === undefined) return undefined;
if (!rule || !rule.enable_status_check) return [];
return (rule.status_check_contexts ?? []).filter(Boolean);
+9
View File
@@ -216,3 +216,12 @@ test("a base's required statuses decide which of the controller's statuses are w
const comment = commentFor(c, {}, ["mesh/repo-check"]);
assert.ok(comment === null || !comment.includes("mesh/merge-gate"), comment ?? "");
});
test("a rule the forge gives with no contexts (null) requires nothing, and an unread rule is unknown", async () => {
const { requiredOf } = await import("../pulls.ts");
// The forge's BranchProtection says status_check_contexts may be null (protection.ts); requiredOf takes it as it comes.
assert.deepEqual(requiredOf({ enable_status_check: true, status_check_contexts: null }), []);
assert.deepEqual(requiredOf({ enable_status_check: true, status_check_contexts: ["mesh/check", ""] }), ["mesh/check"]);
assert.deepEqual(requiredOf(null), []);
assert.equal(requiredOf(undefined), undefined);
});
+1 -1
View File
@@ -128,7 +128,7 @@ export interface TrunkForge {
branchHead(owner: string, repo: string, branch: string): Promise<string>;
/** The rule the forge applies to a branch: which of the controller's statuses are kept there (issue 1301). Not given,
* or not read: both, as before. */
appliedProtection?(owner: string, repo: string, branch: string): Promise<{ enable_status_check?: boolean; status_check_contexts?: string[] } | null>;
appliedProtection?(owner: string, repo: string, branch: string): Promise<{ enable_status_check?: boolean; status_check_contexts?: string[] | null } | null>;
}
/** What it writes with: the merge check's one writer (gate.ts), which decides in the head's own turn. */
@@ -39,12 +39,8 @@ func TestTheBaseParses(t *testing.T) {
t.Skip("logrotate is not installed here; the base configuration is not dry-run")
}
dir := t.TempDir()
content := strings.ReplaceAll(templateText(t, manifest(t).resource(t, "config")), "include "+RulesDir, "include "+filepath.Join(dir, "d"))
if err := os.Mkdir(filepath.Join(dir, "d"), 0o755); err != nil {
t.Fatal(err)
}
conf := filepath.Join(dir, "logrotate.conf")
if err := os.WriteFile(conf, []byte(content), 0o644); err != nil {
if err := os.WriteFile(conf, []byte(dryRunBase(t, dir)), 0o644); err != nil {
t.Fatal(err)
}
out, err := exec.Command(logrotate, "-d", "-s", filepath.Join(dir, "state"), conf).CombinedOutput()
@@ -52,3 +48,40 @@ func TestTheBaseParses(t *testing.T) {
t.Fatalf("logrotate -d: %v\n%s", err, out)
}
}
// The dry run proves the base parses, not what the machine's logs are: the unit level runs shut off in
// bubblewrap (mesh-sdk buildstep.Isolated), where /var/log is not there, and a base block without
// missingok then fails its stat (issue 1891).
func TestTheDryRunReadsNothingOfTheMachine(t *testing.T) {
dir := t.TempDir()
content := dryRunBase(t, dir)
for _, machine := range []string{"include " + RulesDir, "/var/log/"} {
if strings.Contains(content, machine) {
t.Fatalf("the dry run's base still names %s of the machine:\n%s", machine, content)
}
}
for _, log := range []string{"wtmp", "btmp"} {
if _, err := os.Stat(filepath.Join(dir, "log", log)); err != nil {
t.Fatalf("the base's own log %s is not in the dry run's directory: %v", log, err)
}
}
}
// dryRunBase is the base configuration pointed into dir: its include at dir/d, and its own logs at dir/log,
// both made there.
func dryRunBase(t *testing.T, dir string) string {
t.Helper()
content := strings.ReplaceAll(templateText(t, manifest(t).resource(t, "config")), "include "+RulesDir, "include "+filepath.Join(dir, "d"))
content = strings.ReplaceAll(content, "/var/log/", filepath.Join(dir, "log")+"/")
for _, d := range []string{"d", "log"} {
if err := os.Mkdir(filepath.Join(dir, d), 0o755); err != nil {
t.Fatal(err)
}
}
for _, log := range []string{"wtmp", "btmp"} {
if err := os.WriteFile(filepath.Join(dir, "log", log), nil, 0o644); err != nil {
t.Fatal(err)
}
}
return content
}
+2 -11
View File
@@ -37,17 +37,8 @@ Three read tools answer what the module stored in its own states, without the gu
forge-account provision's ssh name, so the account's own ssh files and git rewrite are used) into a workspace of the
work directory, then runs `go mod download` and `go mod verify` in every directory with a `go.mod` that does not vendor,
with `GOTOOLCHAIN=local`, `GOFLAGS=-mod=mod` and the module's cache directory. Every command runs with an
environment built from scratch (`StepRunner`): `PATH`, `HOME` and what the step passes.
## Room to work and the workspace's end
Every step is refused, ending `refused` with `refused: <node> has N GiB free under the floor of 20 GiB (on <dir>)`, while
the work or the cache directory's filesystem has less than 20 GiB free (build-agent's floor, issue 1839); the health is
then unhealthy with the same text, so the builder chooses another node and the controller raises the condition. A
workspace is held while a step runs on it. `build_go_release {workspace}` removes it at once (no guard, no floor; one
already gone is no error, one a running step holds is refused); its caller calls it once its last step answered. A
failed fetch removes its own workspace. A workspace no caller released is removed two hours after its last use (ADR
0509), and the removal is said in the log as the caller's fault.
environment built from scratch (`StepRunner`): `PATH`, `HOME` and what the step passes. Workspaces older than a day
are removed.
A pin on a pull request's head (issue 1148): when `go mod download` fails on a `git.novox.be/novox/*` pseudo-version, the
fetch looks for that commit among the forge's `refs/pull/*/head`, places the repository at it under the workspace's
@@ -23,6 +23,11 @@ var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
const ForgeAlias = "mesh-forge"
// WorkspaceAge is how long a workspace is kept: a module removes the workspaces older than a day, each time it
// fetches and every round it checks its toolchain. A workspace lies in the module's work directory, never in its
// home (ADR 0466 §1).
const WorkspaceAge = 24 * time.Hour
var (
repositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
workspaceOK = regexp.MustCompile(`^[a-z0-9]+$`)
@@ -77,37 +82,20 @@ func (m *Module) fetchTool() StepTool {
}
}
// fetch makes a workspace and fills it. A workspace lies in the module's work directory, never in its home (ADR 0466
// §1); it is held while the fetch runs, and removed when the fetch fails, since nobody learns its id to release it
// (issue 1839).
func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err error) {
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
// What the builder and the checker ask, in mesh-sdk's one form (ADR 0499 §4): with the module's directory and its
// named paths, the workspace is narrowed to them (buildstep.Narrow).
req, err := buildstep.ParseFetchRequest(args)
if err != nil {
return nil, err
}
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
siblings, err := siblingsArg(args["beside"])
if err != nil {
return nil, err
}
m.CleanWorkspaces(m.now())
id := buildstep.NewID()
done := m.spaces().Hold(id)
defer func() {
done()
if err != nil {
if rerr := m.spaces().Release(id); rerr != nil && m.Say != nil {
m.Say("the failed fetch's workspace %s: %v", id, rerr)
}
}
}()
return m.fetchInto(ctx, req, id, siblings)
}
func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, id string, siblings []siblingArg) (any, error) {
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
var err error
root := filepath.Join(m.Work, id)
tree := filepath.Join(root, "tree")
if err := os.MkdirAll(tree, 0o700); err != nil {
@@ -248,10 +236,21 @@ func (m *Module) goEnvFor(dir string) []string {
return env
}
// CleanWorkspaces removes the workspaces no step holds whose last use is older than buildstep.UnheldBound: the safety
// net for a caller that never released one (issue 1839, ADR 0509).
// CleanWorkspaces removes the workspaces older than WorkspaceAge from the work directory.
func (m *Module) CleanWorkspaces(now time.Time) {
m.sweepSaid(m.spaces().Sweep(now, buildstep.UnheldBound))
entries, err := os.ReadDir(m.Work)
if err != nil {
return
}
for _, e := range entries {
info, err := e.Info()
if err != nil || !e.IsDir() || !workspaceOK.MatchString(e.Name()) || now.Sub(info.ModTime()) < WorkspaceAge {
continue
}
if err := os.RemoveAll(filepath.Join(m.Work, e.Name())); err != nil && m.Say != nil {
m.Say("the workspace %s could not be removed: %v", e.Name(), err)
}
}
}
// clock is the time now: Now when a test set it, else the wall clock.
@@ -265,11 +265,10 @@ func TestARepositoryThatIsNotOwnerSlashNameIsRefusedBeforeAnythingRuns(t *testin
}
}
// Unheld workspaces last used more than two hours ago are removed (issue 1839, ADR 0509); younger ones, and anything
// that is not a workspace, stay.
func TestUnheldWorkspacesPastTwoHoursAreRemoved(t *testing.T) {
// Workspaces older than a day are removed; younger ones, and anything that is not a workspace, stay.
func TestWorkspacesOlderThanADayAreRemoved(t *testing.T) {
m := &Module{Work: t.TempDir()}
for name, age := range map[string]time.Duration{"old1": 3 * time.Hour, "young": time.Hour, "Keep-This": 48 * time.Hour} {
for name, age := range map[string]time.Duration{"old1": 25 * time.Hour, "young": time.Hour, "Keep-This": 48 * time.Hour} {
dir := filepath.Join(m.Work, name)
os.MkdirAll(filepath.Join(dir, "tree"), 0o755)
when := time.Now().Add(-age)
@@ -68,6 +68,7 @@ func (m *Module) integrationShutOff(ctx context.Context, workspace string, packa
}
iso := m.isolation(workspace, false)
iso.ReadOnly = append(iso.ReadOnly, exe) // the program alone, not the directory it lies in
iso.ReadOnly = append(iso.ReadOnly, m.nodeToolchains()...)
r := buildstep.Isolated(m.Run, iso)(ctx, nil, exe, levelArg, string(spec))
var answer levelAnswer
line := lastLine(r.Stdout)
@@ -80,6 +81,25 @@ func (m *Module) integrationShutOff(ctx context.Context, workspace string, packa
return answer.Result, nil
}
// NodeToolchains is the Node build module's toolchains directory on this node: its cache directory (module.json
// resource `cache`, mode 0711) joined with toolchain, where it installs typescript, esbuild, @novox/mesh-sdk and nats
// from its own lock (ADR 0533 §1, §2; issue 1891).
const NodeToolchains = "/var/lib/mesh-build-node-cache/toolchain"
// nodeToolchains is what the integration level is given of the Node build module's toolchains: their directory,
// read-only, when it is there and this account may enter it, and nothing on a node where the Node build module has
// installed none (ADR 0533 §1). The unit level is never given it.
func (m *Module) nodeToolchains() []string {
dir := m.NodeToolchains
if dir == "" {
dir = NodeToolchains
}
if _, err := os.ReadDir(dir); err != nil {
return nil
}
return []string{dir}
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
return strings.TrimSpace(lines[len(lines)-1])
@@ -46,7 +46,6 @@ func main() {
Work: WorkDir,
Cache: CacheDir,
}
m.Floor = floorOf(m.Node)
if u, err := user.Current(); err == nil {
m.Home = u.HomeDir
}
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net/http"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
@@ -63,15 +62,12 @@ type Module struct {
Push func(ctx context.Context, registry, repository, digest string, body []byte) error
// Executable is this program's path, which the integration level starts again shut off; os.Executable when empty.
Executable string
// NodeToolchains is where the Node build module keeps its installed toolchains, bound read-only into the
// integration level (ADR 0533 §1); NodeToolchains when empty. A test points it elsewhere.
NodeToolchains string
// StartBus starts the throwaway bus of a run in dir and answers its address and what stops it (the module's own
// program, StartBus in bus.go, when nil).
StartBus func(ctx context.Context, dir string) (url string, stop func(), err error)
// Floor refuses every step while the work or cache filesystem has less free than it (issue 1839); main sets
// floorOf(node), and a zero Floor checks nothing.
Floor buildstep.Floor
spacesOnce sync.Once
workspaces *buildstep.Workspaces
}
// guard is the SDK guard for the version the probe just read: the module names no Go version (ADR 0477 §2), so the
@@ -120,9 +116,6 @@ func (m *Module) Health(ctx context.Context) health.Answer {
if err := m.checkBubblewrap(ctx); err != nil {
return health.Unhealthy(err.Error())
}
if err := m.room(); err != nil {
return health.Unhealthy(err.Error())
}
return health.Healthy(fmt.Sprintf("%s installed, the version the module builds with (recipe %s)", installed, m.Recipe.Version()))
}
@@ -135,10 +128,6 @@ func (m *Module) Long(tool string, run func(ctx context.Context, args map[string
if err := m.Check(ctx); err != nil {
return nil, err
}
if err := m.room(); err != nil {
return nil, err
}
defer m.holdArg(args)()
return run(ctx, args)
})
if err != nil {
@@ -155,10 +144,6 @@ func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any,
if err := m.Check(m.Ctx); err != nil {
return nil, err
}
if err := m.room(); err != nil {
return nil, err
}
defer m.holdArg(args)()
return run(m.Ctx, args)
}
}
@@ -115,7 +115,7 @@ func TestAGoThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
{Name: "short_probe", Run: run},
}, m.StepTools()...)
for _, tool := range m.Tools(steps) {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool {
continue
}
var reason string
@@ -0,0 +1,128 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"slices"
"strconv"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// integrationShutOffWith runs the integration level through the fake bubblewrap and answers what its one shut-off
// process was given.
func integrationShutOffWith(t *testing.T, toolchains string) shutOff {
t.Helper()
m, states, ws, _ := workspaceFor(t, "failing")
m.Executable = "/opt/bundle/mesh-build-go"
m.NodeToolchains = toolchains
want := TestResult{Level: LevelIntegration, Passed: true, Tests: 1, Packages: []PackageResult{}, Store: "postgres 18"}
var seen []shutOff
m.Run = throughBubblewrap(t, func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch {
case name == "postgres" && len(args) == 1 && args[0] == "--version":
return forgekey.Ran{Stdout: "postgres (PostgreSQL) 18.6\n"}
case name == m.Executable && len(args) == 2 && args[0] == levelArg:
raw, _ := json.Marshal(levelAnswer{Result: &want})
return forgekey.Ran{Stdout: string(raw) + "\n"}
}
return forgekey.Ran{Status: 1, Err: "not expected here"}
}, &seen)
run := stepTool(t, m, "build_go_test")
if _, err := run(map[string]any{"workspace": ws, "level": LevelIntegration}); err != nil {
t.Fatal(err)
}
if st := states.waitEnd(t); st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
if len(seen) != 1 {
t.Fatalf("shut off %d processes, want one: %v", len(seen), seen)
}
checkShutOff(t, m, seen[0], false)
return seen[0]
}
// 1891 (ADR 0533 §1): the integration level is given the Node build module's installed toolchains, read-only, so the
// Node build module's own integration tests build with the real tsc and esbuild and reach no registry.
func TestTheIntegrationLevelIsGivenTheNodeToolchainsReadOnly(t *testing.T) {
dir := t.TempDir()
s := integrationShutOffWith(t, dir)
if !slices.Contains(s.roBinds, dir) {
t.Errorf("the node toolchains %s are not bound read-only: %v", dir, s.roBinds)
}
if slices.Contains(s.binds, dir) || slices.Contains(s.overlays, dir) {
t.Errorf("the node toolchains %s may be written: binds %v, overlays %v", dir, s.binds, s.overlays)
}
if slices.Contains(s.roBinds, filepath.Dir(dir)) {
t.Errorf("the whole cache %s is bound, not its toolchains alone: %v", filepath.Dir(dir), s.roBinds)
}
}
// 1891 (ADR 0533 §1): a node where the Node build module has installed no toolchain runs the level as before, with
// nothing more bound (bubblewrap binds only what is there).
func TestANodeWithoutNodeToolchainsRunsTheLevelWithNothingMoreBound(t *testing.T) {
missing := filepath.Join(t.TempDir(), "none")
s := integrationShutOffWith(t, missing)
if slices.Contains(s.roBinds, missing) {
t.Errorf("a missing %s is bound: %v", missing, s.roBinds)
}
}
// 1891 (ADR 0533 §1): the unit level is not given them.
func TestTheUnitLevelIsNotGivenTheNodeToolchains(t *testing.T) {
m, states, ws, _ := workspaceFor(t, "failing")
dir := t.TempDir()
m.NodeToolchains = dir
var seen []shutOff
m.Run = throughBubblewrap(t, m.Run, &seen)
if _, st := runUnit(t, m, states, ws); st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
for _, s := range seen {
if slices.Contains(s.roBinds, dir) || slices.Contains(s.binds, dir) {
t.Errorf("%s was given the node toolchains", s.command)
}
}
}
// 1891 (ADR 0533 §1, §2): the place bound is the Node build module's own: its manifest's cache directory joined with
// toolchain, and that cache directory lets other accounts enter it without listing it (0711).
func TestTheNodeToolchainsAreTheNodeBuildModulesCache(t *testing.T) {
if _, err := catalogue.ReadTreeManifest(catalogueRoot, filepath.Join(catalogueRoot, "modules", "mesh-build-node")); err != nil {
t.Fatalf("mesh-sdk's validator refuses mesh-build-node's manifest: %v", err)
}
raw, err := os.ReadFile(filepath.Join(catalogueRoot, "modules", "mesh-build-node", "module.json"))
if err != nil {
t.Fatal(err)
}
var man struct {
Resources []struct {
ID, Path, Mode string
} `json:"resources"`
}
if err := json.Unmarshal(raw, &man); err != nil {
t.Fatal(err)
}
found := false
for _, r := range man.Resources {
if r.ID != "cache" {
continue
}
found = true
if want := filepath.Join(r.Path, "toolchain"); NodeToolchains != want {
t.Errorf("NodeToolchains = %s, want %s (mesh-build-node's cache)", NodeToolchains, want)
}
mode, err := strconv.ParseUint(r.Mode, 8, 32)
if err != nil || mode&0o001 == 0 || mode&0o004 != 0 {
t.Errorf("mesh-build-node's cache has mode %q; want others to enter it and not list it (0711)", r.Mode)
}
}
if !found {
t.Fatal("mesh-build-node's manifest declares no cache directory")
}
}
@@ -1,61 +0,0 @@
package main
// A step's room to work and its workspace's end (novox/hq issue 1839, ADR 0509): every step is refused while the work
// or cache filesystem is under the floor, a workspace is held while a step runs on it, removed when its caller releases
// it, and swept unheld past buildstep.UnheldBound.
import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// ReleaseTool removes a workspace whose caller's steps have ended.
const ReleaseTool = "build_go_release"
// floorOf is the floor the module keeps on node: build-agent's.
func floorOf(node string) buildstep.Floor {
return buildstep.Floor{Node: node, Bytes: buildstep.FreeSpaceFloor}
}
// room refuses while the work or cache filesystem is under the floor.
func (m *Module) room() error { return m.Floor.Check(m.Work, m.Cache) }
// spaces are the module's workspaces under Work.
func (m *Module) spaces() *buildstep.Workspaces {
m.spacesOnce.Do(func() { m.workspaces = &buildstep.Workspaces{Dir: m.Work} })
return m.workspaces
}
// holdArg holds the workspace a step's arguments name while the step runs; nothing when they name none.
func (m *Module) holdArg(args map[string]any) func() {
ws, _ := args["workspace"].(string)
if ws == "" {
return func() {}
}
return m.spaces().Hold(ws)
}
// releaseTool is build_go_release: no guard and no floor, since it only frees room.
func (m *Module) releaseTool() stdio.Tool {
return stdio.Tool{Name: ReleaseTool,
Description: "Remove a workspace whose steps have ended (fetch, test, build): its caller says so once its last step " +
"answered. A workspace already gone is no error; one a running step holds is refused. A workspace never released " +
"is removed two hours after its last use.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"workspace": map[string]any{"type": "string", "description": "the workspace id a fetch answered"},
}, "required": []string{"workspace"}},
Run: func(args map[string]any) (any, error) {
ws, _ := args["workspace"].(string)
if err := m.spaces().Release(ws); err != nil {
return nil, err
}
return map[string]any{"released": ws}, nil
}}
}
// sweepSaid is CleanWorkspaces' saying of a workspace a caller never released: a fault of the caller (ADR 0509).
func (m *Module) sweepSaid(removed []string) {
if len(removed) > 0 && m.Say != nil {
m.Say("removed %d workspaces no caller released within %s: %v", len(removed), buildstep.UnheldBound, removed)
}
}
@@ -1,126 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"git.novox.be/novox/mesh-sdk/go/health"
)
const underTheFloor = "refused: ace has 4 GiB free under the floor of 20 GiB"
func underFloor(node string) buildstep.Floor {
f := floorOf(node)
f.Free = func(string) (uint64, error) { return 4 << 30, nil }
return f
}
// 1839/AC4: the module as main makes it keeps build-agent's floor of 20 GiB.
func TestTheModuleKeepsTheFloorOf20GiB(t *testing.T) {
if f := floorOf("ace"); f.Bytes != 20<<30 || f.Node != "ace" {
t.Errorf("floorOf = %+v, want ace and 20 GiB", f)
}
}
// 1839/AC1: under the floor every step tool is refused, naming the node, what is free and the floor, no step runs, and
// the module is unhealthy saying so, so the builder chooses another node.
func TestUnderTheFloorEveryStepIsRefusedAndTheModuleIsUnhealthy(t *testing.T) {
states := newStates()
m := moduleWith(states, "go1.27.1")
m.Work, m.Cache, m.Floor = t.TempDir(), t.TempDir(), underFloor("ace")
ran := false
run := func(context.Context, map[string]any) (any, error) { ran = true; return "done", nil }
steps := []StepTool{{Name: "long_probe", Long: true, Run: run}, {Name: "short_probe", Run: run}}
for _, tool := range m.Tools(steps) {
switch tool.Name {
case "long_probe":
if _, err := tool.Run(map[string]any{}); err != nil {
t.Fatal(err)
}
if st := states.waitEnd(t); st.State != delivery.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) {
t.Errorf("the long step ended %s: %q, want refused: %q", st.State, st.Reason, underTheFloor)
}
case "short_probe":
if _, err := tool.Run(map[string]any{}); err == nil || !strings.HasPrefix(err.Error(), underTheFloor) {
t.Errorf("the short step answered %v, want %q", err, underTheFloor)
}
}
}
if ran {
t.Errorf("a step ran under the floor")
}
if a := m.Health(context.Background()); a.State != health.StateUnhealthy || !strings.HasPrefix(a.Why, underTheFloor) {
t.Errorf("health = %+v, want unhealthy: %q", a, underTheFloor)
}
}
func releaseTool(t *testing.T, m *Module) func(map[string]any) (any, error) {
t.Helper()
for _, tool := range m.Tools(m.StepTools()) {
if tool.Name == ReleaseTool {
return tool.Run
}
}
t.Fatalf("no tool %s", ReleaseTool)
return nil
}
// 1839/AC2: build_go_release removes the workspace at once, even under the floor; a second release is no error.
func TestReleaseRemovesTheWorkspaceAtOnce(t *testing.T) {
m, _ := fetchModule(t, &fakeStep{})
m.Floor = underFloor("ace")
ws := filepath.Join(m.Work, "abc1")
os.MkdirAll(filepath.Join(ws, "tree"), 0o700)
for i := 0; i < 2; i++ {
if _, err := releaseTool(t, m)(map[string]any{"workspace": "abc1"}); err != nil {
t.Fatalf("release %d: %v", i, err)
}
}
if _, err := os.Stat(ws); !os.IsNotExist(err) {
t.Errorf("the workspace is still there after its release")
}
}
// 1839/AC2: a fetch that fails leaves no workspace behind: nobody was told its id, so nobody could release it.
func TestAFailedFetchLeavesNoWorkspace(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n", goRule: func(string) forgekey.Ran { return forgekey.Ran{Status: 1, Stderr: "boom"} }}
m, states := fetchModule(t, f)
if _, err := fetchTool(t, m)(fetchArgs); err != nil {
t.Fatal(err)
}
if st := states.waitEnd(t); st.State != delivery.StepFailed {
t.Fatalf("the step ended %s, want failed", st.State)
}
if entries, _ := os.ReadDir(m.Work); len(entries) != 0 {
t.Errorf("the failed fetch left %d entries in the work directory", len(entries))
}
}
// 1839/AC3: a workspace a running step holds is never swept, however old; an unheld one past the two-hour bound is.
func TestTheSweepKeepsAHeldWorkspaceAndRemovesAnUnheldOnePastTwoHours(t *testing.T) {
m := &Module{Work: t.TempDir()}
for _, name := range []string{"held1", "lost1"} {
dir := filepath.Join(m.Work, name)
os.MkdirAll(filepath.Join(dir, "tree"), 0o755)
}
done := m.spaces().Hold("held1")
defer done()
when := time.Now().Add(-3 * time.Hour)
for _, name := range []string{"held1", "lost1"} {
os.Chtimes(filepath.Join(m.Work, name), when, when)
}
m.CleanWorkspaces(time.Now())
if _, err := os.Stat(filepath.Join(m.Work, "held1")); err != nil {
t.Errorf("the held workspace was swept")
}
if _, err := os.Stat(filepath.Join(m.Work, "lost1")); !os.IsNotExist(err) {
t.Errorf("the unheld workspace three hours old was kept")
}
}
@@ -40,7 +40,6 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
tools = append(tools, t)
}
tools = append(tools, m.ReadTools()...)
tools = append(tools, m.releaseTool())
return append(tools,
stdio.Tool{Name: StatusTool,
Description: "What the Go build module is on this machine: the Go version its recipe builds with and the one installed, " +
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
-2
View File
@@ -1,7 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 h1:i85+fFT0wZiTZ9CRVfMzWg1+Zr7oOLl3UH7hEaKcOIc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
-1
View File
@@ -17,7 +17,6 @@
"build_go_test",
"build_go_digest",
"build_go_build",
"build_go_release",
"build_go_step",
"build_go_steps",
"build_go_toolchains",
-6
View File
@@ -37,12 +37,6 @@ The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can re
- It refuses, naming why, an image whose build context is a whole repository (`context`) or whose `build.on` names
another module's artifact (`unsupportedImage` in `build.go`): those forms belong to the images build-agent builds
until it is removed at R4g.
- `build_image_release {workspace}` removes a workspace at once (no guard, no floor; one already gone is no error, one a
running step holds is refused); its caller calls it once its last step answered. A failed fetch removes its own
workspace, and one no caller released is removed two hours after its last use (ADR 0509).
- A fetch and a build are refused, `refused: <node> has N GiB free under the floor of 20 GiB (on <dir>)`, and the health
is unhealthy with that text, while the work directory's or the image store's filesystem has less than 20 GiB free
(issue 1839). A copy writes nothing to the disk and is not refused.
- `build_image_step`, `build_image_steps`, `build_image_status` and `build_image_health` read.
The registry's address is placed into `<home>/registry` as `${seat:mesh-artifact-store:reach}` (host:port): the provisioner's client fills a moved module's placement, and the artifact store has not moved, so a `${bound:artifact-store:...}` is refused at composition (issue 1703).
@@ -26,6 +26,11 @@ var (
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
const ForgeAlias = "mesh-forge"
// WorkspaceAge is how long a workspace is kept: a module removes the workspaces older than a day, each time it
// fetches and every round it checks itself. A workspace lies in the module's work directory, never in its home
// (ADR 0466 §1).
const WorkspaceAge = 24 * time.Hour
// FetchResult is what a passed fetch step says: the workspace it made and the commit in it, and, only when the call
// gave a base, the merge preview of ADR 0484 clause 3 (the trunk commit, the merge commit of the head onto it, the
// files in conflict, the paths the merge changes and the tree of each module directory). A head that does not
@@ -61,9 +66,7 @@ func (m *Module) fetchTool() StepTool {
}
}
// fetch makes a workspace and fills it. The workspace is held while the fetch runs, and removed when the fetch fails,
// since nobody learns its id to release it (issue 1839).
func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err error) {
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
repository, _ := args["repository"].(string)
commit, _ := args["commit"].(string)
ref, _ := args["ref"].(string)
@@ -79,19 +82,6 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err e
}
m.CleanWorkspaces(m.clock())
id := buildstep.NewID()
done := m.spaces().Hold(id)
defer func() {
done()
if err != nil {
if rerr := m.spaces().Release(id); rerr != nil && m.Say != nil {
m.Say("the failed fetch's workspace %s: %v", id, rerr)
}
}
}()
return m.fetchInto(ctx, repository, commit, ref, base, id)
}
func (m *Module) fetchInto(ctx context.Context, repository, commit, ref, base, id string) (any, error) {
tree := filepath.Join(m.Work, id, "tree")
if err := os.MkdirAll(tree, 0o700); err != nil {
return nil, err
@@ -134,10 +124,21 @@ func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base
return err
}
// CleanWorkspaces removes the workspaces no step holds whose last use is older than buildstep.UnheldBound: the safety
// net for a caller that never released one (issue 1839, ADR 0509).
// CleanWorkspaces removes the workspaces older than WorkspaceAge from the work directory.
func (m *Module) CleanWorkspaces(now time.Time) {
m.sweepSaid(m.spaces().Sweep(now, buildstep.UnheldBound))
entries, err := os.ReadDir(m.Work)
if err != nil {
return
}
for _, e := range entries {
info, err := e.Info()
if err != nil || !e.IsDir() || !workspaceOK.MatchString(e.Name()) || now.Sub(info.ModTime()) < WorkspaceAge {
continue
}
if err := os.RemoveAll(filepath.Join(m.Work, e.Name())); err != nil && m.Say != nil {
m.Say("the workspace %s could not be removed: %v", e.Name(), err)
}
}
}
// clock is the time now: Now when a test set it, else the wall clock.
@@ -37,7 +37,6 @@ func main() {
Work: WorkDir,
Store: StoreDir,
}
m.Floor = floorOf(m.Node)
if u, err := user.Current(); err == nil {
m.Home, m.Account, m.UID = u.HomeDir, u.Username, u.Uid
}
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net/http"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
@@ -46,12 +45,6 @@ type Module struct {
RegistryPause time.Duration
// Now is the clock; time.Now when nil.
Now func() time.Time
// Floor refuses every fetch and build while the work or the image store's filesystem has less free than it (issue
// 1839); main sets floorOf(node), and a zero Floor checks nothing.
Floor buildstep.Floor
spacesOnce sync.Once
workspaces *buildstep.Workspaces
}
// Look is what the module found out about its machine.
@@ -111,9 +104,6 @@ func (m *Module) Health(ctx context.Context) health.Answer {
if err != nil {
return health.Unhealthy(err.Error())
}
if err := m.room(); err != nil {
return health.Unhealthy(err.Error())
}
return health.Healthy(fmt.Sprintf("buildah %s installed; %s holds %d subordinate uids and %d gids; a user namespace can be made", l.Buildah, l.Account, l.UIDs, l.GIDs))
}
@@ -136,10 +126,6 @@ func (m *Module) Long(tool string, run func(ctx context.Context, args map[string
if err := m.Check(ctx); err != nil {
return nil, err
}
if err := m.room(); err != nil {
return nil, err
}
defer m.holdArg(args)()
return run(ctx, args)
})
if err != nil {
@@ -156,10 +142,6 @@ func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any,
if err := m.Check(m.Ctx); err != nil {
return nil, err
}
if err := m.room(); err != nil {
return nil, err
}
defer m.holdArg(args)()
return run(m.Ctx, args)
}
}
@@ -1,61 +0,0 @@
package main
// A step's room to work and its workspace's end (novox/hq issue 1839, ADR 0509): every fetch and build is refused while
// the work or image store's filesystem is under the floor, a workspace is held while a step runs on it, removed when its caller releases
// it, and swept unheld past buildstep.UnheldBound.
import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// ReleaseTool removes a workspace whose caller's steps have ended.
const ReleaseTool = "build_image_release"
// floorOf is the floor the module keeps on node: build-agent's.
func floorOf(node string) buildstep.Floor {
return buildstep.Floor{Node: node, Bytes: buildstep.FreeSpaceFloor}
}
// room refuses while the work or the image store's filesystem is under the floor.
func (m *Module) room() error { return m.Floor.Check(m.Work, m.Store) }
// spaces are the module's workspaces under Work.
func (m *Module) spaces() *buildstep.Workspaces {
m.spacesOnce.Do(func() { m.workspaces = &buildstep.Workspaces{Dir: m.Work} })
return m.workspaces
}
// holdArg holds the workspace a step's arguments name while the step runs; nothing when they name none.
func (m *Module) holdArg(args map[string]any) func() {
ws, _ := args["workspace"].(string)
if ws == "" {
return func() {}
}
return m.spaces().Hold(ws)
}
// releaseTool is build_image_release: no guard and no floor, since it only frees room.
func (m *Module) releaseTool() stdio.Tool {
return stdio.Tool{Name: ReleaseTool,
Description: "Remove a workspace whose steps have ended (fetch, build): its caller says so once its last step " +
"answered. A workspace already gone is no error; one a running step holds is refused. A workspace never released " +
"is removed two hours after its last use.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"workspace": map[string]any{"type": "string", "description": "the workspace id a fetch answered"},
}, "required": []string{"workspace"}},
Run: func(args map[string]any) (any, error) {
ws, _ := args["workspace"].(string)
if err := m.spaces().Release(ws); err != nil {
return nil, err
}
return map[string]any{"released": ws}, nil
}}
}
// sweepSaid is CleanWorkspaces' saying of a workspace a caller never released: a fault of the caller (ADR 0509).
func (m *Module) sweepSaid(removed []string) {
if len(removed) > 0 && m.Say != nil {
m.Say("removed %d workspaces no caller released within %s: %v", len(removed), buildstep.UnheldBound, removed)
}
}
@@ -1,117 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"git.novox.be/novox/mesh-sdk/go/health"
)
const underTheFloor = "refused: ace has 4 GiB free under the floor of 20 GiB"
func underFloor(node string) buildstep.Floor {
f := floorOf(node)
f.Free = func(string) (uint64, error) { return 4 << 30, nil }
return f
}
// 1839/AC4: the module as main makes it keeps build-agent's floor of 20 GiB.
func TestTheModuleKeepsTheFloorOf20GiB(t *testing.T) {
if f := floorOf("ace"); f.Bytes != 20<<30 || f.Node != "ace" {
t.Errorf("floorOf = %+v, want ace and 20 GiB", f)
}
}
// 1839/AC1: under the floor a fetch and a build are refused, naming the node, what is free and the floor, nothing runs,
// and the module is unhealthy saying so, so the builder chooses another node.
func TestUnderTheFloorAFetchAndABuildAreRefusedAndTheModuleIsUnhealthy(t *testing.T) {
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
m.Floor = underFloor("ace")
for name, args := range map[string]map[string]any{
FetchTool: {"repository": "novox/mesh-catalog", "commit": commitSHA},
BuildTool: {"workspace": "abc1", "module": "modules/m", "artifact": "image"},
} {
if _, err := tool(t, m, name)(args); err != nil {
t.Fatalf("%s: %v", name, err)
}
if st := states.waitEnd(t); st.State != delivery.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) {
t.Errorf("%s ended %s: %q, want refused: %q", name, st.State, st.Reason, underTheFloor)
}
}
for _, l := range f.lines() {
if strings.HasPrefix(l, "git ") || strings.Contains(l, " bud ") {
t.Errorf("a refused step ran %q", l)
}
}
if a := m.Health(context.Background()); a.State != health.StateUnhealthy || !strings.HasPrefix(a.Why, underTheFloor) {
t.Errorf("health = %+v, want unhealthy: %q", a, underTheFloor)
}
}
// 1839/AC2: build_image_release removes the workspace at once, even under the floor; a second release is no error.
func TestReleaseRemovesTheWorkspaceAtOnce(t *testing.T) {
m := moduleWith(t, newFake(t), newStates())
m.Floor = underFloor("ace")
ws := filepath.Join(m.Work, "abc1")
os.MkdirAll(filepath.Join(ws, "tree"), 0o700)
for i := 0; i < 2; i++ {
if _, err := tool(t, m, ReleaseTool)(map[string]any{"workspace": "abc1"}); err != nil {
t.Fatalf("release %d: %v", i, err)
}
}
if _, err := os.Stat(ws); !os.IsNotExist(err) {
t.Errorf("the workspace is still there after its release")
}
}
// 1839/AC2: a fetch that fails leaves no workspace behind: nobody was told its id, so nobody could release it.
func TestAFailedFetchLeavesNoWorkspace(t *testing.T) {
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
if name == "git" && strings.Contains(strings.Join(args, " "), " fetch ") {
return forgekey.Ran{Status: 128, Stderr: "fatal: unreachable"}
}
return f.run(ctx, env, name, args...)
}
if _, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}); err != nil {
t.Fatal(err)
}
if st := states.waitEnd(t); st.State != delivery.StepFailed {
t.Fatalf("the step ended %s (%s), want failed", st.State, st.Reason)
}
if entries, _ := os.ReadDir(m.Work); len(entries) != 0 {
t.Errorf("the failed fetch left %d entries in the work directory", len(entries))
}
}
// 1839/AC3: a workspace a running step holds is never swept, however old; an unheld one past the two-hour bound is.
func TestTheSweepKeepsAHeldWorkspaceAndRemovesAnUnheldOnePastTwoHours(t *testing.T) {
m := &Module{Work: t.TempDir()}
for _, name := range []string{"held1", "lost1"} {
os.MkdirAll(filepath.Join(m.Work, name, "tree"), 0o755)
}
done := m.spaces().Hold("held1")
defer done()
when := time.Now().Add(-3 * time.Hour)
for _, name := range []string{"held1", "lost1"} {
os.Chtimes(filepath.Join(m.Work, name), when, when)
}
m.CleanWorkspaces(time.Now())
if _, err := os.Stat(filepath.Join(m.Work, "held1")); err != nil {
t.Errorf("the held workspace was swept")
}
if _, err := os.Stat(filepath.Join(m.Work, "lost1")); !os.IsNotExist(err) {
t.Errorf("the unheld workspace three hours old was kept")
}
}
@@ -44,7 +44,6 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
tools = append(tools, t)
}
tools = append(tools, m.ReadTools()...)
tools = append(tools, m.releaseTool())
return append(tools,
stdio.Tool{Name: StatusTool,
Description: "What the image build module is on this machine: the buildah installed, the account's subordinate " +
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
-2
View File
@@ -1,7 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 h1:i85+fFT0wZiTZ9CRVfMzWg1+Zr7oOLl3UH7hEaKcOIc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467/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/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
-1
View File
@@ -15,7 +15,6 @@
"build_image_fetch",
"build_image_build",
"build_image_copy",
"build_image_release",
"build_image_step",
"build_image_steps",
"build_image_status",
+1 -5
View File
@@ -57,11 +57,7 @@ toolchain install: a long step answers at once with its id and ends on `<node>.<
removed). The mesh's registry reaches `npm ci` through a private config file (`--userconfig`), so
other `@novox/` packages install. A module with packages and no lock does not fail the fetch: its result lists it
under `refused` with the reason, and a build of that bundle fails naming it (issue 1459). Every command runs through `forgekey.ExecRunnerClean(0)` with PATH, HOME, the locale and its own
words, nothing of this process's environment. A failed fetch removes its own workspace; `build_node_release {workspace}` removes one at once (no guard, no
floor; its caller calls it once its last step answered), and one no caller released is removed two hours after its
last use (ADR 0509), never while a step holds it. Every step is refused, `refused: <node> has N GiB free under the
floor of 20 GiB (on <dir>)`, and the health is unhealthy with that text, while the work or the toolchain cache
filesystem has less than 20 GiB free (issue 1839).
words, nothing of this process's environment. Workspaces older than a day are removed at each fetch.
The arguments are mesh-sdk's `buildstep.FetchRequest` (ADR 0499 §4): with `dir` and `paths` (from the builder, never
from a manifest) the workspace is narrowed to those paths, the manifest and the lock files before anything else. A
member is built against the SDK version its own lock names (the lead's decision L10 of R4e, ADR 0500 §5-§6): the
@@ -3,18 +3,25 @@
package main
import (
"bytes"
"context"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// realMachine installs the real toolchain from the module's own lock (npm ci --ignore-scripts, from the mesh's
// registry for @novox, which reads without a credential) and makes two workspaces holding one fixture bundle.
// realMachine gives the tests the real toolchain of the module's own lock and makes two workspaces holding one fixture
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1): the integration level
// is given its toolchains read-only and no network, so it is copied into the test's own cache from there. Only where
// no toolchain is installed (a desk) is it installed with `npm ci --ignore-scripts` from the mesh's registry, as the
// module does, which needs the network.
func realMachine(t *testing.T) (*Module, string) {
t.Helper()
for _, p := range []string{"node", "npm", "bwrap"} { // bwrap: tsc, esbuild and the scripts run shut off (1309)
@@ -40,8 +47,11 @@ func realMachine(t *testing.T) (*Module, string) {
return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil
}}}
withStore(t, m)
if from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json")); from != "" {
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
}
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil {
t.Fatal(err)
t.Fatalf("no toolchain of this lock is installed under %s, and installing it from the registry failed: %v", filepath.Join(CacheDir, "toolchain"), err)
}
for _, id := range []string{"w1", "w2"} {
for p, body := range map[string]string{
@@ -115,3 +125,74 @@ func TestAPackageWhoseTestsImportItsBuildPassesTheUnitLevel(t *testing.T) {
t.Errorf("result = %+v", r)
}
}
// installedToolchain is the toolchain directory under toolchains that this node's Node build module installed: the
// one installed from the lock at lockPath, byte for byte, or, when the change carries a lock no delivered release has
// installed yet, the one installed last, said in the test's log (ADR 0533 §3: a new lock is proven by the module that
// installs it on its first node). "" when none is there or the directory cannot be read: no Node build module here.
func installedToolchain(t *testing.T, toolchains, lockPath string) string {
t.Helper()
want, err := os.ReadFile(lockPath)
if err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(toolchains)
if err != nil {
return ""
}
last, lastAt := "", time.Time{}
for _, e := range entries {
dir := filepath.Join(toolchains, e.Name())
mark, err := os.Stat(filepath.Join(dir, installedMark))
if err != nil {
continue
}
got, err := os.ReadFile(filepath.Join(dir, "package-lock.json"))
if err != nil {
continue
}
if bytes.Equal(got, want) {
t.Logf("the toolchain is this node's installed %s, of this change's lock", dir)
return dir
}
if mark.ModTime().After(lastAt) {
last, lastAt = dir, mark.ModTime()
}
}
if last != "" {
t.Logf("NOT THIS LOCK: no toolchain of this change's lock is installed here; the tests run with the one installed last, %s", last)
}
return last
}
// copyToolchain copies the installed toolchain from into to whole, files and folders with their modes, so the test's
// steps run behind a toolchain of their own and the installed one is only read.
func copyToolchain(t *testing.T, from, to string) {
t.Helper()
err := filepath.WalkDir(from, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(from, p)
dst := filepath.Join(to, rel)
info, err := d.Info()
if err != nil {
return err
}
switch {
case d.IsDir():
return os.MkdirAll(dst, 0o755)
case info.Mode().IsRegular():
raw, err := os.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(dst, raw, info.Mode().Perm()|0o600)
default:
return fmt.Errorf("%s is neither a file nor a folder", p)
}
})
if err != nil {
t.Fatalf("copying the installed toolchain %s: %v", from, err)
}
}
@@ -33,6 +33,9 @@ const ForgeAlias = "mesh-forge"
// sdkPackage is the package the toolchain carries and a module's own dependencies never supply.
const sdkPackage = "@novox/mesh-sdk"
// WorkspaceAge is how long a workspace is kept.
const WorkspaceAge = 24 * time.Hour
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
var repositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
@@ -102,36 +105,20 @@ func (m *Module) fetchTool() StepTool {
}
}
// fetch makes a workspace and fills it. The workspace is held while the fetch runs, and removed when the fetch fails,
// since nobody learns its id to release it (issue 1839).
func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err error) {
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
// What the builder and the checker ask, in mesh-sdk's one form (ADR 0499 §4): with the module's directory and its
// named paths, the workspace is narrowed to them (buildstep.Narrow).
req, err := buildstep.ParseFetchRequest(args)
if err != nil {
return nil, err
}
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
group, err := groupPackageArg(args["package"])
if err != nil {
return nil, err
}
m.CleanWorkspaces(m.now())
id := buildstep.NewID()
done := m.spaces().Hold(id)
defer func() {
done()
if err != nil {
if rerr := m.spaces().Release(id); rerr != nil && m.Say != nil {
m.Say("the failed fetch's workspace %s: %v", id, rerr)
}
}
}()
return m.fetchInto(ctx, req, group, id)
}
func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, group *groupPackage, id string) (any, error) {
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
var err error
root := filepath.Join(m.Work, id)
tree := filepath.Join(root, "tree")
if err := os.MkdirAll(tree, 0o700); err != nil {
@@ -367,8 +354,19 @@ func removeSDKCopies(nm string) error {
return nil
}
// CleanWorkspaces removes the workspaces no step holds whose last use is older than buildstep.UnheldBound: the safety
// net for a caller that never released one (issue 1839, ADR 0509).
// CleanWorkspaces removes the workspaces older than WorkspaceAge from the work directory.
func (m *Module) CleanWorkspaces(now time.Time) {
m.sweepSaid(m.spaces().Sweep(now, buildstep.UnheldBound))
entries, err := os.ReadDir(m.Work)
if err != nil {
return
}
for _, e := range entries {
info, err := e.Info()
if err != nil || !e.IsDir() || !workspaceOK.MatchString(e.Name()) || now.Sub(info.ModTime()) < WorkspaceAge {
continue
}
if err := os.RemoveAll(filepath.Join(m.Work, e.Name())); err != nil && m.Say != nil {
m.Say("the workspace %s could not be removed: %v", e.Name(), err)
}
}
}
@@ -54,7 +54,6 @@ func main() {
Bubblewrap: ProbeBubblewrap,
RegistryFile: filepath.Join(home, "registry"),
}
m.Floor = floorOf(m.Node)
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
if m.Node != "" {
go m.Start(ctx)
@@ -59,14 +59,8 @@ type Module struct {
// unhealthy, while it is missing or older than BubblewrapLeast. Nil probes nothing.
Bubblewrap func(ctx context.Context) (string, error)
// Floor refuses every step while the work or toolchain cache filesystem has less free than it (issue 1839); main
// sets floorOf(node), and a zero Floor checks nothing.
Floor buildstep.Floor
spacesOnce sync.Once
workspaces *buildstep.Workspaces
mu sync.Mutex // guards Recipe
install sync.Mutex // one install at a time
mu sync.Mutex // guards Recipe
install sync.Mutex // one install at a time
}
func (m *Module) recipe() Recipe {
@@ -128,7 +122,6 @@ func (m *Module) Start(ctx context.Context) {
if err := m.Prepare(ctx); err != nil && m.Say != nil {
m.Say("the toolchain: %v", err)
}
m.CleanWorkspaces(m.now())
select {
case <-ctx.Done():
return
@@ -145,9 +138,6 @@ func (m *Module) Health(ctx context.Context) health.Answer {
if err != nil {
return health.Unhealthy(err.Error())
}
if err := m.room(); err != nil {
return health.Unhealthy(err.Error())
}
return health.Healthy(fmt.Sprintf("node %s installed, the version the module builds with (recipe %s)", installed, m.recipe().Version()))
}
@@ -160,10 +150,6 @@ func (m *Module) Long(tool string, run func(ctx context.Context, args map[string
if err := m.Prepare(ctx); err != nil {
return nil, err
}
if err := m.room(); err != nil {
return nil, err
}
defer m.holdArg(args)()
return run(ctx, args)
})
if err != nil {
@@ -180,10 +166,6 @@ func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any,
if err := m.Prepare(m.Ctx); err != nil {
return nil, err
}
if err := m.room(); err != nil {
return nil, err
}
defer m.holdArg(args)()
return run(m.Ctx, args)
}
}
@@ -117,7 +117,7 @@ func TestANodeThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T)
{Name: "short_probe", Run: run},
}, (&Module{}).StepTools()...)
for _, tool := range m.Tools(steps) {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool {
continue
}
var reason string
@@ -1,67 +0,0 @@
package main
// A step's room to work and its workspace's end (novox/hq issue 1839, ADR 0509): every step is refused while the work
// or toolchain cache filesystem is under the floor, a workspace is held while a step runs on it, removed when its caller releases
// it, and swept unheld past buildstep.UnheldBound.
import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// ReleaseTool removes a workspace whose caller's steps have ended.
const ReleaseTool = "build_node_release"
// floorOf is the floor the module keeps on node: build-agent's.
func floorOf(node string) buildstep.Floor {
return buildstep.Floor{Node: node, Bytes: buildstep.FreeSpaceFloor}
}
// room refuses while the work or toolchain cache filesystem is under the floor.
func (m *Module) room() error {
paths := []string{m.Work}
if m.Toolchain != nil {
paths = append(paths, m.Toolchain.Paths.Cache)
}
return m.Floor.Check(paths...)
}
// spaces are the module's workspaces under Work.
func (m *Module) spaces() *buildstep.Workspaces {
m.spacesOnce.Do(func() { m.workspaces = &buildstep.Workspaces{Dir: m.Work} })
return m.workspaces
}
// holdArg holds the workspace a step's arguments name while the step runs; nothing when they name none.
func (m *Module) holdArg(args map[string]any) func() {
ws, _ := args["workspace"].(string)
if ws == "" {
return func() {}
}
return m.spaces().Hold(ws)
}
// releaseTool is build_node_release: no guard and no floor, since it only frees room.
func (m *Module) releaseTool() stdio.Tool {
return stdio.Tool{Name: ReleaseTool,
Description: "Remove a workspace whose steps have ended (fetch, test, build): its caller says so once its last step " +
"answered. A workspace already gone is no error; one a running step holds is refused. A workspace never released " +
"is removed two hours after its last use.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"workspace": map[string]any{"type": "string", "description": "the workspace id a fetch answered"},
}, "required": []string{"workspace"}},
Run: func(args map[string]any) (any, error) {
ws, _ := args["workspace"].(string)
if err := m.spaces().Release(ws); err != nil {
return nil, err
}
return map[string]any{"released": ws}, nil
}}
}
// sweepSaid is CleanWorkspaces' saying of a workspace a caller never released: a fault of the caller (ADR 0509).
func (m *Module) sweepSaid(removed []string) {
if len(removed) > 0 && m.Say != nil {
m.Say("removed %d workspaces no caller released within %s: %v", len(removed), buildstep.UnheldBound, removed)
}
}
@@ -1,123 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"git.novox.be/novox/mesh-sdk/go/health"
)
const underTheFloor = "refused: ace has 4 GiB free under the floor of 20 GiB"
func underFloor(node string) buildstep.Floor {
f := floorOf(node)
f.Free = func(string) (uint64, error) { return 4 << 30, nil }
return f
}
// 1839/AC4: the module as main makes it keeps build-agent's floor of 20 GiB.
func TestTheModuleKeepsTheFloorOf20GiB(t *testing.T) {
if f := floorOf("ace"); f.Bytes != 20<<30 || f.Node != "ace" {
t.Errorf("floorOf = %+v, want ace and 20 GiB", f)
}
}
// 1839/AC1: under the floor every step tool is refused, naming the node, what is free and the floor, no step runs, and
// the module is unhealthy saying so, so the builder chooses another node.
func TestUnderTheFloorEveryStepIsRefusedAndTheModuleIsUnhealthy(t *testing.T) {
states := newStates()
m := moduleWith(states, "v26.10.0")
m.Work, m.Floor = t.TempDir(), underFloor("ace")
ran := false
run := func(context.Context, map[string]any) (any, error) { ran = true; return "done", nil }
steps := []StepTool{{Name: "long_probe", Long: true, Run: run}, {Name: "short_probe", Run: run}}
for _, tool := range m.Tools(steps) {
switch tool.Name {
case "long_probe":
if _, err := tool.Run(map[string]any{}); err != nil {
t.Fatal(err)
}
if st := states.waitEnd(t); st.State != delivery.StepRefused || !strings.HasPrefix(st.Reason, underTheFloor) {
t.Errorf("the long step ended %s: %q, want refused: %q", st.State, st.Reason, underTheFloor)
}
case "short_probe":
if _, err := tool.Run(map[string]any{}); err == nil || !strings.HasPrefix(err.Error(), underTheFloor) {
t.Errorf("the short step answered %v, want %q", err, underTheFloor)
}
}
}
if ran {
t.Errorf("a step ran under the floor")
}
if a := m.Health(context.Background()); a.State != health.StateUnhealthy || !strings.HasPrefix(a.Why, underTheFloor) {
t.Errorf("health = %+v, want unhealthy: %q", a, underTheFloor)
}
}
func releaseTool(t *testing.T, m *Module) func(map[string]any) (any, error) {
t.Helper()
for _, tool := range m.Tools(m.StepTools()) {
if tool.Name == ReleaseTool {
return tool.Run
}
}
t.Fatalf("no tool %s", ReleaseTool)
return nil
}
// 1839/AC2: build_node_release removes the workspace at once, even under the floor; a second release is no error.
func TestReleaseRemovesTheWorkspaceAtOnce(t *testing.T) {
m, _, work := newMachine(t)
m.Floor = underFloor("ace")
ws := filepath.Join(work, "abc1")
os.MkdirAll(filepath.Join(ws, "tree"), 0o700)
for i := 0; i < 2; i++ {
if _, err := releaseTool(t, m)(map[string]any{"workspace": "abc1"}); err != nil {
t.Fatalf("release %d: %v", i, err)
}
}
if _, err := os.Stat(ws); !os.IsNotExist(err) {
t.Errorf("the workspace is still there after its release")
}
}
// 1839/AC2: a fetch that fails leaves no workspace behind: nobody was told its id, so nobody could release it.
func TestAFailedFetchLeavesNoWorkspace(t *testing.T) {
m, f := fetchMachine(t, typeScriptModule(`{}`), nil)
f.answer["git fetch"] = forgekey.Ran{Status: 128, Stderr: "fatal: unreachable"}
if _, err := m.fetch(context.Background(), fetchArgs); err == nil {
t.Fatal("the fetch passed, want it failed")
}
if entries, _ := os.ReadDir(m.Work); len(entries) != 0 {
t.Errorf("the failed fetch left %d entries in the work directory", len(entries))
}
}
// 1839/AC3: a workspace a running step holds is never swept, however old; an unheld one past the two-hour bound is,
// also at the module's own round and not only at a fetch.
func TestTheSweepKeepsAHeldWorkspaceAndRemovesAnUnheldOnePastTwoHours(t *testing.T) {
m := &Module{Work: t.TempDir()}
for _, name := range []string{"held1", "lost1"} {
os.MkdirAll(filepath.Join(m.Work, name, "tree"), 0o755)
}
done := m.spaces().Hold("held1")
defer done()
when := time.Now().Add(-3 * time.Hour)
for _, name := range []string{"held1", "lost1"} {
os.Chtimes(filepath.Join(m.Work, name), when, when)
}
m.CleanWorkspaces(time.Now())
if _, err := os.Stat(filepath.Join(m.Work, "held1")); err != nil {
t.Errorf("the held workspace was swept")
}
if _, err := os.Stat(filepath.Join(m.Work, "lost1")); !os.IsNotExist(err) {
t.Errorf("the unheld workspace three hours old was kept")
}
}
@@ -10,6 +10,7 @@ package main
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
@@ -53,14 +54,17 @@ func (t Toolchain) Present(version string) bool {
// are copied in, and `npm ci --ignore-scripts` runs once, the mesh's registry named for the @novox scope. A directory
// an earlier install left unfinished is removed first.
func (t Toolchain) Ensure(ctx context.Context, version string) error {
if t.Present(version) {
return nil
}
dir := t.Paths.Dir(version)
if t.Present(version) {
if opened(dir) {
return nil
}
return t.open(dir)
}
if err := os.RemoveAll(dir); err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, name := range []string{"package.json", "package-lock.json"} {
@@ -76,18 +80,95 @@ func (t Toolchain) Ensure(ctx context.Context, version string) error {
if err != nil {
return err
}
npmrc := filepath.Join(dir, ".npmrc-install")
if err := os.WriteFile(npmrc, []byte(reg.Npmrc()), 0o600); err != nil {
// The credential is written in the account's own home (0700), never beneath the cache, whose toolchains are
// opened to every account (ADR 0533 §2), and removed as soon as npm has read it.
f, err := os.CreateTemp(t.Paths.Home, ".npmrc-install-")
if err != nil {
return err
}
npmrc := f.Name()
defer os.Remove(npmrc)
_, err = f.WriteString(reg.Npmrc())
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return err
}
ran := t.Run(ctx, []string{"PATH=" + pathOrDefault(), "HOME=" + t.Paths.Home, "LC_ALL=C", "npm_config_cache=" + filepath.Join(t.Paths.Cache, "npm")},
"npm", "ci", "--ignore-scripts", "--no-bin-links", "--no-audit", "--no-fund", "--prefix", dir, "--userconfig", npmrc)
os.Remove(npmrc)
if ran.Err != "" || ran.Status != 0 {
return fmt.Errorf("npm ci --ignore-scripts for the toolchain %s failed (status %d %s): %s",
version, ran.Status, ran.Err, tail(ran.Stderr))
}
return os.WriteFile(filepath.Join(dir, installedMark), []byte(version+"\n"), 0o600)
if err := os.WriteFile(filepath.Join(dir, installedMark), []byte(version+"\n"), 0o600); err != nil {
return err
}
return t.open(dir)
}
// refuseNpmConfig refuses a toolchain folder that holds an install's npm config file (one a dead install of an older release left with
// the registry credential in it), naming it, before anything of the folder is opened.
func refuseNpmConfig(dir string) error {
return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasPrefix(d.Name(), ".npmrc-install") {
return fmt.Errorf("the toolchain %s holds the npm config file %s, which may hold the registry credential, "+
"so it is not opened to other accounts; remove it", dir, p)
}
return nil
})
}
// opened says whether the toolchain directory dir was opened to other accounts: its installed mark, opened last, is
// readable by others.
func opened(dir string) bool {
st, err := os.Stat(filepath.Join(dir, installedMark))
return err == nil && st.Mode().Perm()&0o004 != 0
}
// open lets every account read the toolchain directory dir and nobody but its owner write it (ADR 0533 §2): the
// toolchains folder and every folder beneath dir 0755, every file 0644 and every program 0755, whatever umask npm ran
// under; the installed mark last, so a toolchain opened only in part is opened again. The other build module's
// integration level reads it through the cache directory, which others may enter and not list (0711); the npm cache
// beside it stays the owner's.
func (t Toolchain) open(dir string) error {
if err := refuseNpmConfig(dir); err != nil {
return err
}
if err := os.Chmod(filepath.Join(t.Paths.Cache, "toolchain"), 0o755); err != nil {
return err
}
mark := filepath.Join(dir, installedMark)
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if p == mark {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
switch mode := info.Mode(); {
case mode.IsDir():
return os.Chmod(p, 0o755)
case mode.IsRegular() && mode.Perm()&0o100 != 0:
return os.Chmod(p, 0o755)
case mode.IsRegular():
return os.Chmod(p, 0o644)
default:
return fmt.Errorf("%s is neither a file nor a folder, and a toolchain holds only those", p)
}
})
if err != nil {
return fmt.Errorf("the toolchain %s cannot be opened to the integration level: %w", dir, err)
}
return os.Chmod(mark, 0o644)
}
func tail(s string) string {
@@ -0,0 +1,166 @@
package main
import (
"context"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// npmAsUmask077 installs as npm does under a umask of 077: node_modules with a package whose folders are 0700, its
// file 0600 and its program 0700.
func npmAsUmask077(f *fakeNpm) forgekey.Runner {
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
ran := f.run(ctx, env, name, args...)
for i, a := range args {
if a == "--prefix" {
pkg := filepath.Join(args[i+1], "node_modules", "typescript", "bin")
os.MkdirAll(pkg, 0o700)
os.WriteFile(filepath.Join(args[i+1], "node_modules", "typescript", "package.json"), []byte("{}"), 0o600)
os.WriteFile(filepath.Join(pkg, "tsc"), []byte("#!/bin/sh\n"), 0o700)
}
}
return ran
}
}
// checkOpen fails unless every folder from the cache's toolchain down lets others enter and read it and every file lets
// others read it, a program others run, and nothing lets anyone but the owner write.
func checkOpen(t *testing.T, tc Toolchain) {
t.Helper()
root := filepath.Join(tc.Paths.Cache, "toolchain")
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
mode := info.Mode().Perm()
switch {
case mode&0o022 != 0:
t.Errorf("%s is %o: writable by others than its owner", p, mode)
case d.IsDir() && mode&0o005 != 0o005:
t.Errorf("%s is %o: others may not enter and read it", p, mode)
case !d.IsDir() && mode&0o004 == 0:
t.Errorf("%s is %o: others may not read it", p, mode)
case !d.IsDir() && mode&0o100 != 0 && mode&0o001 == 0:
t.Errorf("%s is %o: a program others may not run", p, mode)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
// 1891 (ADR 0533 §2): a toolchain the module installs can be read by the other build module's integration level:
// its folders 0755 and its files 0644 (programs 0755), whatever umask npm ran under, and writable by its owner alone.
func TestAnInstalledToolchainIsReadableByOthersAndWritableByItsOwnerAlone(t *testing.T) {
fake := &fakeNpm{}
tc := toolchainIn(t, fake)
tc.Run = npmAsUmask077(fake)
npm := filepath.Join(tc.Paths.Cache, "npm")
if err := os.MkdirAll(npm, 0o700); err != nil {
t.Fatal(err)
}
if err := tc.Ensure(context.Background(), "v1"); err != nil {
t.Fatal(err)
}
checkOpen(t, tc)
if st, err := os.Stat(npm); err != nil || st.Mode().Perm() != 0o700 {
t.Errorf("the npm download cache beside the toolchains is %v (%v), want 0700: only the toolchains are opened", st.Mode().Perm(), err)
}
}
// 1891 (ADR 0533 §2): a toolchain installed before this release, shut to its owner, is opened by the next Ensure
// without installing it again.
func TestAToolchainInstalledShutIsOpenedWithoutAnInstall(t *testing.T) {
fake := &fakeNpm{}
tc := toolchainIn(t, fake)
dir := tc.Paths.Dir("v1")
os.MkdirAll(filepath.Join(dir, "node_modules", "esbuild", "bin"), 0o700)
os.WriteFile(filepath.Join(dir, "node_modules", "esbuild", "bin", "esbuild"), []byte("x"), 0o700)
os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0o600)
os.WriteFile(filepath.Join(dir, installedMark), []byte("v1\n"), 0o600)
os.Chmod(filepath.Join(tc.Paths.Cache, "toolchain"), 0o700)
if err := tc.Ensure(context.Background(), "v1"); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Errorf("npm ran %d times for a toolchain already there", len(fake.calls))
}
checkOpen(t, tc)
}
// 1891 (ADR 0533 §2): the cache directory lets other accounts enter it and not list it, so the toolchains beneath it
// can be reached by their path and the npm cache beside them (0700) cannot.
func TestTheCacheDirectoryIsEnteredByOthersNotListed(t *testing.T) {
r := resource(t, readManifest(t), "cache")
if r["mode"] != "0711" {
t.Errorf("the cache directory's mode is %v, want 0711", r["mode"])
}
}
// 1891 review (ADR 0533 §2): the registry credential npm is told is never written beneath the cache, so opening a
// toolchain can never open it: npm reads it from a file in the account's own home, readable by its owner alone, and
// the file is gone when Ensure returns.
func TestTheInstallCredentialIsNeverInTheFolderThatIsOpened(t *testing.T) {
fake := &fakeNpm{}
tc := toolchainIn(t, fake)
var userconfig string
inner := npmAsUmask077(fake)
tc.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
for i, a := range args {
if a == "--userconfig" {
userconfig = args[i+1]
}
}
if st, err := os.Stat(userconfig); err != nil || st.Mode().Perm() != 0o600 {
t.Errorf("the credential file %s is %v (%v), want 0600", userconfig, st.Mode().Perm(), err)
}
return inner(ctx, env, name, args...)
}
if err := tc.Ensure(context.Background(), "v1"); err != nil {
t.Fatal(err)
}
if rel, err := filepath.Rel(tc.Paths.Home, userconfig); err != nil || rel != filepath.Base(userconfig) {
t.Errorf("the credential file %s is not in the account's home %s", userconfig, tc.Paths.Home)
}
if _, err := os.Stat(userconfig); err == nil {
t.Errorf("the credential file %s is left after the install", userconfig)
}
filepath.WalkDir(tc.Paths.Cache, func(p string, d fs.DirEntry, err error) error {
if err == nil && strings.Contains(d.Name(), "npmrc") {
t.Errorf("a credential file %s lies beneath the cache", p)
}
return nil
})
}
// 1891 review (ADR 0533 §2): open refuses a toolchain folder that holds an npm config file, naming it, and opens
// nothing of it, so a credential left by a dead install is never made readable.
func TestOpenRefusesAFolderHoldingAnNpmConfig(t *testing.T) {
fake := &fakeNpm{}
tc := toolchainIn(t, fake)
dir := tc.Paths.Dir("v1")
os.MkdirAll(filepath.Join(dir, "node_modules"), 0o700)
left := filepath.Join(dir, ".npmrc-install")
os.WriteFile(left, []byte("//x/:_auth=secret\n"), 0o600)
os.WriteFile(filepath.Join(dir, installedMark), []byte("v1\n"), 0o600)
err := tc.Ensure(context.Background(), "v1")
if err == nil || !strings.Contains(err.Error(), left) {
t.Errorf("Ensure = %v, want a refusal naming %s", err, left)
}
if st, _ := os.Stat(left); st.Mode().Perm() != 0o600 {
t.Errorf("the credential file was opened to %o", st.Mode().Perm())
}
if st, _ := os.Stat(dir); st.Mode().Perm()&0o005 != 0 {
t.Errorf("the folder holding it was opened to %o", st.Mode().Perm())
}
}
@@ -40,7 +40,6 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
tools = append(tools, t)
}
tools = append(tools, m.ReadTools()...)
tools = append(tools, m.releaseTool())
return append(tools,
stdio.Tool{Name: StatusTool,
Description: "What the Node build module is on this machine: the Node version its recipe builds with and the one installed, " +
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0
)
-2
View File
@@ -1,7 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467 h1:i85+fFT0wZiTZ9CRVfMzWg1+Zr7oOLl3UH7hEaKcOIc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023062828-6147a2045467/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+1 -2
View File
@@ -16,7 +16,6 @@
"build_node_fetch",
"build_node_test",
"build_node_build",
"build_node_release",
"build_node_step",
"build_node_steps",
"build_node_toolchains",
@@ -88,7 +87,7 @@
"id": "cache",
"type": "directory",
"path": "/var/lib/mesh-build-node-cache",
"mode": "0700",
"mode": "0711",
"owner": "mesh-build-node",
"unknown": "program"
},
+2
View File
@@ -0,0 +1,2 @@
# The test script compiles the sources beside themselves before the tests import them (package.json, issue 1891).
*.js
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
"test": "tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.7"
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"build": "tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
@@ -57,7 +57,7 @@ func TestPacmanConfIsWholeTheUnionOfRepositoriesAndTheImprovedOptions(t *testing
t.Skip("pacman-conf is not installed here; the rendered file is not proven")
}
file := filepath.Join(t.TempDir(), "pacman.conf")
if err := os.WriteFile(file, []byte(content), 0o644); err != nil {
if err := os.WriteFile(file, []byte(provenConf(t, content, filepath.Dir(file))), 0o644); err != nil {
t.Fatal(err)
}
repos, err := exec.Command(conf, "--config", file, "--repo-list").CombinedOutput()
@@ -74,6 +74,35 @@ func TestPacmanConfIsWholeTheUnionOfRepositoriesAndTheImprovedOptions(t *testing
}
}
// Every repository of the shipped file reads the mirror list reflector writes, and the file pacman-conf proves reads
// nothing of the machine: the unit level runs shut off (novox/hq ADR 0448), where /etc/pacman.d is not there
// (issue 1891).
func TestTheProvenConfReadsNothingOfTheMachine(t *testing.T) {
content := templateText(t, manifest(t).resource(t, "config"))
if n := strings.Count(content, "Include = "+Mirrorlist+"\n"); n != 3 || strings.Count(content, "Include") != n {
t.Fatalf("every repository reads %s, and nothing else is included: %d of 3\n%s", Mirrorlist, n, content)
}
dir := t.TempDir()
proven := provenConf(t, content, dir)
if strings.Contains(proven, "/etc/") {
t.Fatalf("the proven file still names the machine's /etc:\n%s", proven)
}
if _, err := os.Stat(filepath.Join(dir, "mirrorlist")); err != nil {
t.Fatalf("the proven file's mirror list is not in its directory: %v", err)
}
}
// provenConf is the shipped pacman.conf with its repositories reading a mirror list of its own in dir, made there,
// so pacman-conf proves the file the module ships and reads nothing of the machine.
func provenConf(t *testing.T, content, dir string) string {
t.Helper()
list := filepath.Join(dir, "mirrorlist")
if err := os.WriteFile(list, []byte("Server = https://mirror.example/$repo/os/$arch\n"), 0o644); err != nil {
t.Fatal(err)
}
return strings.ReplaceAll(content, "Include = "+Mirrorlist, "Include = "+list)
}
func TestReflectorWritesTheListPacmanReads(t *testing.T) {
content := templateText(t, manifest(t).resource(t, "mirrors"))
opts := map[string]string{}
@@ -311,6 +311,8 @@ func TestAnUnreachableHostIsNotRefreshed(t *testing.T) {
}
func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) {
// The test's own agent, none: the account running it may have one, and the unit level runs with none (issue 1891).
t.Setenv("SSH_AUTH_SOCK", "")
ok := "debug1: Connecting to ace.internal [10.0.0.2] port 22.\ndebug1: Server accepts key: /h/.ssh/id_ed25519 ED25519 " + edFP + "\nAuthenticated to ace.internal ([10.0.0.2]:22) using \"publickey\".\n"
f := &fake{answer: func(c call) forgekey.Ran { return forgekey.Ran{Stderr: ok} }}
got, err := client(t.TempDir(), f).Test(context.Background(), "ace")
@@ -327,9 +329,15 @@ func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) {
if got["ok"] != false || got["why"] != "no key it offered was accepted" || !reflect.DeepEqual(got["offered"], []string{"/h/.ssh/id_box"}) {
t.Fatalf("%v", got)
}
if !strings.Contains(got["note"].(string), "agent") {
if note, _ := got["note"].(string); !strings.Contains(note, "agent") {
t.Fatalf("no word on the agent: %v", got)
}
agent := filepath.Join(t.TempDir(), "agent.sock")
t.Setenv("SSH_AUTH_SOCK", agent)
got, _ = client(t.TempDir(), f).Test(context.Background(), "ace")
if got["agent"] != agent || got["note"] != nil {
t.Fatalf("the environment's agent is not the one it ran with, or a passphrase note without a passphrase: %v", got)
}
}
func TestCheckFindsWhatIsWrongAndSaysWhatItDidNotCheck(t *testing.T) {