Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c0024e43b | ||
|
|
a2e5a20f52 | ||
|
|
845479d7cd | ||
|
|
2ebcb18097 | ||
|
|
00e54d46d7 | ||
|
|
933370df7c | ||
|
|
437c524b81 | ||
|
|
dc96d7c2dd | ||
|
|
f9247342d1 | ||
|
|
b1d875410a |
@@ -0,0 +1,2 @@
|
||||
# The test script compiles the sources beside themselves before the tests import them (package.json, issue 1891).
|
||||
*.js
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, " +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, " +
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# The test script compiles the sources beside themselves before the tests import them (package.json, issue 1891).
|
||||
*.js
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user