diff --git a/modules/mesh-build-go/buildstep/isolate.go b/modules/mesh-build-go/buildstep/isolate.go index 2efa739ed..13deb72b2 100644 --- a/modules/mesh-build-go/buildstep/isolate.go +++ b/modules/mesh-build-go/buildstep/isolate.go @@ -27,16 +27,19 @@ const Bubblewrap = "bwrap" // - the system's programs and libraries read-only, and of /etc only what a program needs to run (users, libraries, // certificates, time zone), with a hosts file naming only the loopback; // - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step -// downloaded) read-only; +// downloaded) read-only, and the ReadOnlyInside ones (inside the Workspace) read-only over it; // - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone // (WriteCaches), so a candidate's code never changes what a later build reads. type Isolation struct { - Workspace string - Writable []string - ReadOnly []string - Caches []string - WriteCaches bool - Env []string + Workspace string + Writable []string + ReadOnly []string + // ReadOnlyInside are directories inside the Workspace or a Writable one bound read-only after them, so they stay + // read-only (a later bind of a path wins; a ReadOnly one inside the Workspace would be covered by it). + ReadOnlyInside []string + Caches []string + WriteCaches bool + Env []string // Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback. Hosts string } @@ -91,7 +94,7 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin for _, group := range []struct { what string dirs []string - }{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}} { + }{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"read-only directory", iso.ReadOnlyInside}, {"cache", iso.Caches}} { for _, d := range group.dirs { if err := checkDir(group.what, d); err != nil { return nil, err @@ -118,6 +121,9 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin for _, d := range iso.Writable { argv = append(argv, "--bind", d, d) } + for _, d := range iso.ReadOnlyInside { + argv = append(argv, "--ro-bind", d, d) + } if iso.Hosts != "" { argv = append(argv, "--ro-bind", iso.Hosts, "/etc/hosts") } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/kept_test.go b/modules/mesh-build-go/cmd/mesh-build-go/kept_test.go index 615f8b36a..92dc78d3b 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/kept_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/kept_test.go @@ -31,7 +31,7 @@ func toolOf(t *testing.T, m *Module, name string) func(map[string]any) (any, err } // Seams review must 2 (issue 1921): the kept composers and programs are listed with their size and last use, dropped one -// by one, and swept when unused past KeptBound; a use keeps a program. +// by one, and swept with the workspaces when unused past KeptBound; a use keeps a program. func TestKeptProgramsAreListedDroppedAndSwept(t *testing.T) { m, _ := fetchModule(t, &fakeStep{}) now := time.Now().UTC().Truncate(time.Second) @@ -62,9 +62,8 @@ func TestKeptProgramsAreListedDroppedAndSwept(t *testing.T) { t.Error("the dropped program is still kept") } - if swept := m.SweepKept(now, KeptBound); len(swept) != 1 || swept[0].Key != "old" { - t.Fatalf("swept %+v, want the old composer only", swept) - } + // The sweep runs with the workspaces' (CleanWorkspaces, on its timer). + m.CleanWorkspaces(now) if got := m.Kept(); len(got) != 1 || got[0].Key != "recent" { t.Fatalf("kept after the sweep = %+v", got) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/main.go b/modules/mesh-build-go/cmd/mesh-build-go/main.go index 03a21b9af..b6189674b 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/main.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/main.go @@ -7,6 +7,7 @@ package main import ( "context" + "encoding/json" "fmt" "os" "os/user" @@ -58,7 +59,7 @@ func main() { m.Settings = m.Home + "/settings.json" m.ReadKept = readOf(stdio.State(KeptState)).Get m.ReadManifest = readOf(stdio.State(ManifestsState)).Get - m.ReadInput = readOf(stdio.State(InputsState)).Get + m.ReadInput = func(state, key string) (json.RawMessage, error) { return readOf(stdio.State(state)).Get(key) } m.Probe = ProbeGo(m.Home, m.Cache) m.Bubblewrap = ProbeBubblewrap // Outside a launch by the runtime — a build, a check — there is no node and no state to write. diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module.go b/modules/mesh-build-go/cmd/mesh-build-go/module.go index 6cf40a48e..b8dc764ac 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module.go @@ -93,8 +93,8 @@ type Module struct { // ReadManifest reads a key of the delivery indexer's manifests state (ManifestsState), which a compose ask's request // names its manifests by (compose.go); GoModCache, when set, is the Go module cache in place of Cache's (a test). ReadManifest func(key string) (json.RawMessage, error) - // ReadInput reads a key of the checker's inputs state (InputsState), which a run ask's input names by its digest. - ReadInput func(key string) (json.RawMessage, error) + // ReadInput reads a key of a state the module reads (module.json), where a run ask's input names its digest. + ReadInput func(state, key string) (json.RawMessage, error) // PlaceHead, when set, lays a run's head at a directory instead of fetching it (a test). PlaceHead func(ctx context.Context, h delivery.RunHead, dir string) error GoModCache string diff --git a/modules/mesh-build-go/cmd/mesh-build-go/run.go b/modules/mesh-build-go/cmd/mesh-build-go/run.go index ce14ea74a..358dac35c 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/run.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/run.go @@ -4,7 +4,7 @@ package main // checking program of a group's check. The module builds the program at the commit the ask names, once per // delivery.RunKey, as a compose ask builds a composer (programOf); fetches every head the ask names and lays each read-only // under the directory MESH_RUN_HEADS names, at `/`; reads the input the ask names by its digest from the -// checker's inputs state and writes it to the file MESH_RUN_INPUT names; and runs the program shut off with the ask's +// state the ask names and writes it to the file MESH_RUN_INPUT names; and runs the program shut off with the ask's // arguments: no network, none of the account. The answer is delivery.RunAnswer: the program's exit, the last line of its // standard output and the first line of its standard error. A program that fails is an answer, not a fault of the step. @@ -22,15 +22,9 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" ) -// InputsState is the checker's state that keeps a run's input by its digest (the module reads it, module.json reads). -const InputsState = delivery.CheckerModule + ".inputs" - // run runs a run ask: the program of its commit, built once, over its heads and its input. func (m *Module) run(ctx context.Context, fetch buildstep.StepFunc, a delivery.StepAsk) (any, error) { job := *a.Run - if job.Input.State != InputsState { - return nil, buildstep.Refuse("the run ask %s keeps its input in %s; this module reads %s", a.ID, job.Input.State, InputsState) - } body, err := m.inputOf(job.Input) if err != nil { return nil, err @@ -53,17 +47,18 @@ func (m *Module) run(ctx context.Context, fetch buildstep.StepFunc, a delivery.S return ans, nil } -// inputOf is the input a run names, read from the checker's inputs state and checked against its digest and size. +// inputOf is the input a run names, read from the state the ask names (module.json reads it) and checked against its +// digest and size. func (m *Module) inputOf(in delivery.RunInput) ([]byte, error) { if m.ReadInput == nil { return nil, errors.New("this module reads no inputs state") } - raw, err := m.ReadInput(in.Digest) + raw, err := m.ReadInput(in.State, in.Digest) if err != nil { return nil, err } if raw == nil { - return nil, fmt.Errorf("the checker keeps no input of digest %s", in.Digest) + return nil, fmt.Errorf("%s keeps no input of digest %s", in.State, in.Digest) } if err := in.Verify(raw); err != nil { return nil, buildstep.Refuse("%v", err) @@ -115,10 +110,9 @@ func (m *Module) runProgram(ctx context.Context, exe string, job delivery.RunJob if !m.Inside { iso := m.isolation(id, false) iso.ReadOnly = append(iso.ReadOnly, exe) + iso.ReadOnlyInside = []string{heads} // bound after the workspace, so the program cannot change a head run = buildstep.Isolated(m.Run, iso) } - // The heads are bound read-only inside the writable workspace by running the program with them as its read-only - // directory too (a later bind of the same path wins). r := run(ctx, []string{delivery.RunHeadsEnv + "=" + heads, delivery.RunInputEnv + "=" + file}, exe, job.Args...) if r.Err != "" && r.Status == 0 { return delivery.RunAnswer{}, fmt.Errorf("the checking program could not run: %s", r.Err) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/run_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/run_integration_test.go index c1463d2a8..77775eb1e 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/run_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/run_integration_test.go @@ -22,8 +22,8 @@ import ( // 1921/C3: a run ask put on the Go build seat's check stream is pulled by the module started whole on the real bus and // answered on its step key .: the program, run shut off over its heads and its input (read from the -// checker's inputs state by digest), answers its last line and exit; a program that dials the machine's network is -// shut off, and the answer says it failed. +// state the ask names by digest), answers its last line and exit; a program cannot write under its heads, and one +// that dials the machine's network is shut off, and the answer says it failed. func TestARunAskIsAnsweredOnTheBusAndItsProgramIsShutOff(t *testing.T) { needTools(t, "the shut-off run", "bash", "bwrap") nc, err := nats.Connect(testbus.URL(t)) @@ -59,13 +59,13 @@ func TestARunAskIsAnsweredOnTheBusAndItsProgramIsShutOff(t *testing.T) { } m.PlaceHead = func(_ context.Context, h delivery.RunHead, dir string) error { return os.MkdirAll(dir, 0o700) } body := `{"v":1}` - m.ReadInput = func(string) (json.RawMessage, error) { return json.RawMessage(body), nil } + m.ReadInput = func(string, string) (json.RawMessage, error) { return json.RawMessage(body), nil } a := runAskOf(t, body) key := delivery.RunKey(a.Run.Program, delivery.ToolchainUsed{Version: "go1.26.0", Recipe: m.Recipe.Version()}, nil) exe := m.programPath("programs", key) os.MkdirAll(filepath.Dir(exe), 0o700) - script := "#!/bin/bash\n" + `echo "start"; exec 3<>/dev/tcp/127.0.0.1/` + strings.Split(tcp.Addr().String(), ":")[1] + " || exit 7\n" + `echo '{"dialed":true}'` + "\n" + script := "#!/bin/bash\n" + `if touch "$MESH_RUN_HEADS/novox/mesh-sdk/x" 2>/dev/null; then echo '{"wrote":true}'; exit 9; fi; echo "start"; exec 3<>/dev/tcp/127.0.0.1/` + strings.Split(tcp.Addr().String(), ":")[1] + " || exit 7\n" + `echo '{"dialed":true}'` + "\n" if err := os.WriteFile(exe, []byte(script), 0o700); err != nil { t.Fatal(err) } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/run_test.go b/modules/mesh-build-go/cmd/mesh-build-go/run_test.go index 10baa06a8..71c4a95eb 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/run_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/run_test.go @@ -12,13 +12,17 @@ import ( sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/composer" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/forgekey" ) const runCommit = "5555555555555555555555555555555555555555" +// runInputs is the state a run ask's input is kept in, as the checker names it in the ask. +const runInputs = "mesh-delivery-checker.inputs" + func runAskOf(t *testing.T, body string) delivery.StepAsk { t.Helper() - in, err := delivery.RunInputOf(InputsState, []byte(body)) + in, err := delivery.RunInputOf(runInputs, []byte(body)) if err != nil { t.Fatal(err) } @@ -35,8 +39,8 @@ func runAskOf(t *testing.T, body string) delivery.StepAsk { func TestAKeptProgramIsRunOverItsHeadsAndInput(t *testing.T) { m := composeModule(t) body := `{"v":1,"case":"x"}` - m.ReadInput = func(key string) (json.RawMessage, error) { - if key != runAskOf(t, body).Run.Input.Digest { + m.ReadInput = func(state, key string) (json.RawMessage, error) { + if state != runInputs || key != runAskOf(t, body).Run.Input.Digest { return nil, nil } return json.RawMessage(body), nil @@ -68,15 +72,35 @@ func TestAKeptProgramIsRunOverItsHeadsAndInput(t *testing.T) { if entries, _ := os.ReadDir(m.Work); len(entries) != 0 { t.Errorf("the run's workspace was left: %v", entries) } - // An input other than the one named is refused, naming it; an unknown state is refused. - m.ReadInput = func(string) (json.RawMessage, error) { return json.RawMessage(`{"other":1}`), nil } + // Shut off, the heads are bound read-only after the writable workspace, so a write under MESH_RUN_HEADS fails: a + // read-only bind before the workspace's is covered by it. + m.Inside = false + var argv []string + m.Run = func(_ context.Context, _ []string, _ string, args ...string) forgekey.Ran { + argv = args + return forgekey.Ran{Status: 0, Stdout: "{}"} + } + if _, err := m.run(context.Background(), fetch, a); err != nil { + t.Fatal(err) + } + line, ws, heads := " "+strings.Join(argv, " ")+" ", -1, -1 + for i := 0; i+2 < len(argv); i++ { + switch { + case argv[i] == "--bind" && filepath.Dir(argv[i+1]) == m.Work: + ws = i + case argv[i] == "--ro-bind" && filepath.Base(argv[i+1]) == "heads" && argv[i+2] == argv[i+1]: + heads = i + } + } + if ws < 0 || heads < ws || !strings.Contains(line, " "+delivery.RunHeadsEnv+" "+argv[heads+1]+" ") { + t.Errorf("the heads are not bound read-only over the workspace: %s", line) + } + m.Inside, m.Run = true, StepRunner(t.TempDir()) + // An input other than the one named is refused, naming it. + m.ReadInput = func(string, string) (json.RawMessage, error) { return json.RawMessage(`{"other":1}`), nil } if _, err := m.run(context.Background(), fetch, a); err == nil || !strings.Contains(err.Error(), "not the one the ask names") { t.Errorf("a wrong input: %v", err) } - a.Run.Input.State = "mesh-delivery-indexer.manifests" - if _, err := m.run(context.Background(), fetch, a); err == nil || !strings.Contains(err.Error(), "this module reads") { - t.Errorf("another state: %v", err) - } } // 1921/C2 (F2): a compose ask's seats reach the composer: the module decodes and fills the request and writes it again,