From 5fc4052a2ba52cc2e3dc7972f1ae0f11430bbdd6 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 00:29:03 +0200 Subject: [PATCH] Run a run-once process as its oneshot unit (novox/hq design 38 WP4c) A step was run directly: in the host's own working directory, without its env, env files or user. A module step moved out of its container (node bootstrap/index.js) could find neither its code nor its words. A oneshot unit carries all four as a daemon's does, and starting it waits. --- internal/apply/process.go | 21 ++++++-- internal/apply/process_step_test.go | 81 +++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 internal/apply/process_step_test.go diff --git a/internal/apply/process.go b/internal/apply/process.go index a548d44..abeef34 100644 --- a/internal/apply/process.go +++ b/internal/apply/process.go @@ -115,9 +115,22 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner, // so the machine is not asked to start something that needed a migration that did not happen. // Nothing is left behind to ask afterwards: the record that it ran is the digest, which is why // the identity above includes the command. + // + // **Run as the unit a daemon would be, once** (novox/hq design 38 WP4c). Run directly, the + // step started in the host's own working directory, without its environment, its environment + // files or its user — `node bootstrap/index.js` resolved from wherever the host ran and was told + // none of the words it was declared with. A oneshot unit carries all four exactly as a daemon's + // does, and starting one waits for it to finish and fails when it fails. if r.RunOnce { - if _, err := run(ctx, r.Run[0], r.Run[1:]...); err != nil { - return out, fmt.Errorf("the %s step did not complete: %w", r.Name, err) + unit := filepath.Join(unitDir, r.Name+".service") + if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil { + return out, err + } + if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil { + return out, err + } + if _, err := run(ctx, "systemctl", "start", r.Name+".service"); err != nil { + return out, fmt.Errorf("the %s step did not complete (journalctl -u %s.service says why): %w", r.Name, r.Name, err) } out.Action = "created" if previous.Wrote != "" { @@ -215,8 +228,8 @@ func unitFor(r *declaration.Process) string { fmt.Fprintf(&b, "User=%s\n", r.User) } fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(runFrom(r), " ")) - if r.Schedule != "" { - // Started by its timer and expected to finish. Restarting it would have it run + if r.Schedule != "" || r.RunOnce { + // Started by its timer, or once by the host, and expected to finish. Restarting it would have it run // continuously between fires, which is the opposite of a schedule. b.WriteString("Type=oneshot\n") b.WriteString("\n") diff --git a/internal/apply/process_step_test.go b/internal/apply/process_step_test.go new file mode 100644 index 0000000..bfda159 --- /dev/null +++ b/internal/apply/process_step_test.go @@ -0,0 +1,81 @@ +package apply + +import ( + "context" + "errors" + "os" + "os/user" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// A run-once process is a step run where, how and as whom it was declared (novox/hq design 38 +// WP4c): its bundle's directory, its environment and environment files, its user. Run directly, +// it started in the host's own directory with none of them. +func TestARunOnceProcessRunsAsItsOneshotUnit(t *testing.T) { + units, bundles := t.TempDir(), t.TempDir() + wasUnits, wasBundles := unitDir, daemonRoot + unitDir, daemonRoot = units, bundles + t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles }) + + me, err := user.Current() + if err != nil { + t.Fatal(err) + } + body, digest := anArchive(t, map[string]string{"bootstrap/index.js": "console.log(1)\n"}) + var commands []string + fail := false + run := func(ctx context.Context, name string, args ...string) (string, error) { + commands = append(commands, name+" "+strings.Join(args, " ")) + if fail && name == "systemctl" && len(args) > 0 && args[0] == "start" { + return "", errors.New("exit status 1") + } + return "", nil + } + d := declare(t, `{"id":"mosquitto.bootstrap","type":"process","name":"mosquitto-bootstrap","source":"`+serving(t, body)+ + `","digest":"`+digest+`","run":["node","bootstrap/index.js"],"run-once":true,"user":"`+me.Username+`",`+ + `"env":{"MESH_ADMIN":"mesh-admin"},"env-file":["/var/lib/mesh/mosquitto/bootstrap.env"]}`) + + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil); err != nil { + t.Fatal(err) + } + unit, err := os.ReadFile(filepath.Join(units, "mosquitto-bootstrap.service")) + if err != nil { + t.Fatalf("no unit was written for the step: %v", err) + } + for _, want := range []string{ + "WorkingDirectory=" + filepath.Join(bundles, "mosquitto-bootstrap"), + "EnvironmentFile=/var/lib/mesh/mosquitto/bootstrap.env", + `Environment="MESH_ADMIN=mesh-admin"`, + "User=" + me.Username, + "Type=oneshot", + "ExecStart=node bootstrap/index.js", + } { + if !strings.Contains(string(unit), want) { + t.Errorf("the step's unit lacks %q:\n%s", want, unit) + } + } + for _, never := range []string{"Restart=always", "[Install]", "Type=simple"} { + if strings.Contains(string(unit), never) { + t.Errorf("a step's unit says %q:\n%s", never, unit) + } + } + joined := strings.Join(commands, "; ") + if !strings.Contains(joined, "systemctl start mosquitto-bootstrap.service") { + t.Errorf("the step was not started as its unit: %s", joined) + } + if strings.Contains(joined, "node bootstrap/index.js") || strings.Contains(joined, "enable mosquitto-bootstrap") { + t.Errorf("the step was run directly or enabled: %s", joined) + } + + // A step that fails fails the apply, and is not recorded as done. + fail = true + d2 := declare(t, `{"id":"mosquitto.bootstrap","type":"process","name":"mosquitto-bootstrap","source":"`+serving(t, body)+ + `","digest":"`+digest+`","run":["node","bootstrap/index.js"],"run-once":true,"env":{"MESH_ADMIN":"changed"}}`) + if _, _, err := Apply(context.Background(), archHost(t), d2, store.State{}, store.OriginDeclared, run, nil, nil); err == nil { + t.Error("a step that failed did not fail the apply") + } +} -- 2.54.0