Add the Node build module's toolchain lock, main, recipe, guard and key keeper (issues 1488, 1484), part 16 of 19
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
// The module's own forge key (novox/hq ADR 0466 §2, issue 1484): mesh-sdk's key keeper, kind builder, for the
|
||||
// module's account in its home. It makes ~/.ssh/mesh_ed25519 when it is missing and writes one entry to this
|
||||
// module's `keys` state at <node>.<account>, which gitea's provider reads to register the key on the forge user
|
||||
// that reads and pushes nothing (ADR 0466 §3).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// keeperFor is this module's keeper: go/forgekey's, of kind builder, for account in home on node, whose entry lies
|
||||
// at <node>.<account> of the keys state.
|
||||
func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper {
|
||||
return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindBuilder, Run: run, State: state, Now: time.Now}
|
||||
}
|
||||
|
||||
// startKeeper runs the keeper when the runtime launched this process for a node: outside a launch — a build, a
|
||||
// check — there is no state to put the entry in.
|
||||
func startKeeper(ctx context.Context) {
|
||||
node := strings.TrimSpace(os.Getenv("MESH_NODE"))
|
||||
u, err := user.Current()
|
||||
if node == "" || err != nil || u.HomeDir == "" {
|
||||
return
|
||||
}
|
||||
k := keeperFor(u.HomeDir, node, u.Username, forgekey.ExecRunner, keysState{})
|
||||
k.Keep(ctx, func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-build-node] "+f+"\n", a...) })
|
||||
}
|
||||
|
||||
type keysState struct{}
|
||||
|
||||
func (keysState) Put(key string, value any) (uint64, error) {
|
||||
return stdio.State("keys").Put(key, value)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// mesh-build-node: the Node and TypeScript build module (novox/hq ADR 0405, ADR 0466 §1, ADR 0469, ADR 0470; design
|
||||
// 47 R4c-3, issue 1488). A Go tools bundle the node's runtime launches as the module's own account (runs-as). It names
|
||||
// the one Node version it builds with, installs the TypeScript toolchain its own lock names, writes the Node version
|
||||
// it finds to <node>.typescript of its `toolchains` state at start and before every step, and refuses every step on
|
||||
// a node that has another: its health is then unhealthy, so the controller raises the node's condition. What it
|
||||
// says, it says on stderr; stdout is the MCP channel.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// CacheDir is the cache directory, outside the account's home (ADR 0466 §1; module.json resource `cache`).
|
||||
const CacheDir = "/var/lib/mesh-build-node-cache"
|
||||
|
||||
// WorkDir is the work directory, outside the home (module.json resource `work`).
|
||||
const WorkDir = "/var/lib/mesh-build-node-work"
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-build-node] "+format+"\n", a...) }
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
home := ""
|
||||
if u, err := user.Current(); err == nil {
|
||||
home = u.HomeDir
|
||||
}
|
||||
paths := Paths{Home: home, Cache: CacheDir}
|
||||
registry := func() (Registry, error) {
|
||||
return ReadRegistry(filepath.Join(home, "package-registry.json"), filepath.Join(home, "package-registry.secret"))
|
||||
}
|
||||
m := &Module{
|
||||
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
|
||||
Reload: func() (Recipe, error) { return ReadRecipe(paths.LockPath()) },
|
||||
Probe: ProbeNode,
|
||||
Toolchain: &Toolchain{Paths: paths, Run: forgekey.ExecRunnerClean(0), Registry: registry},
|
||||
Toolchains: stdio.State(delivery.ToolchainsState).Put,
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
Work: WorkDir,
|
||||
Run: forgekey.ExecRunnerClean(0),
|
||||
Ctx: ctx,
|
||||
Say: logf,
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
go startKeeper(ctx)
|
||||
if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"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"
|
||||
)
|
||||
|
||||
// CheckRound is how often the module reads the installed toolchain again, beside the check before every step and
|
||||
// the one every health look makes.
|
||||
const CheckRound = 10 * time.Minute
|
||||
|
||||
// Module is the Node build module's one running state: the node it runs on, its recipe, the toolchain guard, the
|
||||
// toolchain directory and the states it writes (toolchains, steps).
|
||||
type Module struct {
|
||||
Node string
|
||||
// Recipe is the recipe in force. Reload, when set, reads it again at every check (the lock file the mesh
|
||||
// writes can change under a running module); a failure to read it is a refusal.
|
||||
Recipe Recipe
|
||||
Reload func() (Recipe, error)
|
||||
// Probe reads the installed Node version (ProbeNode).
|
||||
Probe func(ctx context.Context) (string, error)
|
||||
// Toolchain installs the toolchain directory; nil in a module that only guards.
|
||||
Toolchain *Toolchain
|
||||
// Work is the work directory, outside the home: a workspace is <Work>/<step id>/tree. Run runs the commands of a
|
||||
// step (a fake in a test). Clock is the time the steps are timed by.
|
||||
Work string
|
||||
Run forgekey.Runner
|
||||
Clock func() time.Time
|
||||
// Toolchains and Steps are the Put of the states of those names.
|
||||
Toolchains, Steps buildstep.Put
|
||||
// Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not).
|
||||
Ctx context.Context
|
||||
Say func(format string, a ...any)
|
||||
|
||||
mu sync.Mutex // guards Recipe
|
||||
install sync.Mutex // one install at a time
|
||||
}
|
||||
|
||||
func (m *Module) recipe() Recipe {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.Recipe
|
||||
}
|
||||
|
||||
func (m *Module) guard(r Recipe) buildstep.Guard {
|
||||
return buildstep.Guard{Language: "typescript", Want: r.Node, Recipe: r.Version(), Probe: m.Probe, Node: m.Node, Put: m.Toolchains}
|
||||
}
|
||||
|
||||
// Check reads the installed toolchain, writes <node>.typescript of the toolchains state and answers nil, or the
|
||||
// refusal (buildstep.Guard.Check). It runs at start, before every step, at every health look and every CheckRound.
|
||||
func (m *Module) Check(ctx context.Context) error {
|
||||
if m.Reload != nil {
|
||||
r, err := m.Reload()
|
||||
if err != nil {
|
||||
return &buildstep.Refusal{Text: err.Error(), Want: RecipeNode}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.Recipe = r
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return m.guard(m.recipe()).Check(ctx)
|
||||
}
|
||||
|
||||
// Prepare is Check and then the toolchain directory of the recipe in force: what every step runs behind.
|
||||
func (m *Module) Prepare(ctx context.Context) error {
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Toolchain == nil {
|
||||
return nil
|
||||
}
|
||||
m.install.Lock()
|
||||
defer m.install.Unlock()
|
||||
return m.Toolchain.Ensure(ctx, m.recipe().Version())
|
||||
}
|
||||
|
||||
// Start prepares once, as the module starts, and then every CheckRound until ctx ends.
|
||||
func (m *Module) Start(ctx context.Context) {
|
||||
for {
|
||||
if err := m.Prepare(ctx); err != nil && m.Say != nil {
|
||||
m.Say("the toolchain: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(CheckRound):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health is the module's health (the runtime-health tool, ADR 0366): unhealthy while the toolchain installed is not
|
||||
// the recipe's, saying both versions, so the controller raises the node's condition. It looks again each time it is
|
||||
// asked.
|
||||
func (m *Module) Health(ctx context.Context) health.Answer {
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
r := m.recipe()
|
||||
return health.Healthy(fmt.Sprintf("node %s installed, as the recipe %s builds with", r.Node, r.Version()))
|
||||
}
|
||||
|
||||
// Long is a tool that runs a long step (ADR 0469 §2): it answers at once with the step's id, and the step ends on
|
||||
// its key in the steps state. The guard runs inside the step, before run, so a node that refuses ends every step
|
||||
// refused with both versions named.
|
||||
func (m *Module) Long(tool string, run func(ctx context.Context, args map[string]any) (any, error)) func(map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
id, err := buildstep.Start(m.Ctx, m.Steps, m.Node, tool, func(ctx context.Context) (any, error) {
|
||||
if err := m.Prepare(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return run(ctx, args)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, _ := delivery.StepKey(m.Node, id)
|
||||
return map[string]any{"step": id, "key": key, "state": delivery.StepRunning}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Short is a tool that answers in its own call; the guard runs before it, and a refusal is the call's error.
|
||||
func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any, error)) func(map[string]any) (any, error) {
|
||||
return func(args map[string]any) (any, error) {
|
||||
if err := m.Prepare(m.Ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return run(m.Ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
// Status is what the module reports of itself: the node, the recipe, the toolchain found and why it refuses, if it
|
||||
// does.
|
||||
func (m *Module) Status(ctx context.Context) map[string]any {
|
||||
err := m.Check(ctx)
|
||||
installed, _ := m.Probe(ctx)
|
||||
r := m.recipe()
|
||||
out := map[string]any{"node": m.Node, "recipe": r.Version(), "node-version": r.Node, "installed": installed, "refusing": err != nil}
|
||||
if m.Toolchain != nil {
|
||||
out["toolchain-dir"] = m.Toolchain.Paths.Dir(r.Version())
|
||||
out["toolchain-installed"] = m.Toolchain.Present(r.Version())
|
||||
}
|
||||
if err != nil {
|
||||
out["why"] = err.Error()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Module) now() time.Time {
|
||||
if m.Clock != nil {
|
||||
return m.Clock()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// defaultPath is where a step looks for node, npm, git and ssh when this process was given no PATH.
|
||||
const defaultPath = "/usr/local/bin:/usr/bin:/bin"
|
||||
|
||||
// baseEnv is the environment of a step's command beyond what the step adds: PATH, the account's own HOME (its ssh
|
||||
// files and git configuration) and a fixed locale. A command is given these and its own words, never this process's
|
||||
// environment, which holds the module's bus credentials (ADR 0466 §4).
|
||||
func (m *Module) baseEnv() []string {
|
||||
return []string{"PATH=" + pathOrDefault(), "HOME=" + m.Toolchain.homeOrEmpty(), "LC_ALL=C"}
|
||||
}
|
||||
|
||||
func pathOrDefault() string {
|
||||
if path := os.Getenv("PATH"); path != "" {
|
||||
return path
|
||||
}
|
||||
return defaultPath
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProbeTimeout bounds `node --version`.
|
||||
const ProbeTimeout = 15 * time.Second
|
||||
|
||||
// ProbeNode reads the installed Node version: what `node --version` prints ("v26.10.0"), as RecipeNode is written.
|
||||
func ProbeNode(ctx context.Context) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "node", "--version").Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseNodeVersion(string(out))
|
||||
}
|
||||
|
||||
func parseNodeVersion(out string) (string, error) {
|
||||
v := strings.TrimSpace(out)
|
||||
if len(v) < 2 || v[0] != 'v' || v[1] < '0' || v[1] > '9' || strings.ContainsAny(v, " \n\t") {
|
||||
return "", fmt.Errorf("`node --version` answered %q", v)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
// The recipe of the Node build module (novox/hq ADR 0405 §1 and §7, ADR 0469 §1 and §4; issue 1488): the one Node
|
||||
// version it builds with, and the recipe's own version, a hash of the recipe's version, that Node version and the
|
||||
// bytes of the lock file that names the TypeScript toolchain. A node whose installed Node differs from the recipe's
|
||||
// refuses every step (module.go), so all builders run this version or none builds.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// RecipeNode is the Node version the recipe builds with: the text `node --version` prints for it, which is what the
|
||||
// guard compares and what the indexer reads from <node>.typescript of the toolchains state.
|
||||
const RecipeNode = "v26.10.0"
|
||||
|
||||
// RecipeSteps is the recipe's own version: changed whenever the steps of fetch, test or build change in a way that
|
||||
// can change what they produce (ADR 0405 §7). It is one of the three values the recipe version hashes.
|
||||
const RecipeSteps = "1"
|
||||
|
||||
// Recipe is every value that makes up the recipe version.
|
||||
type Recipe struct {
|
||||
// Steps is RecipeSteps.
|
||||
Steps string
|
||||
// Node is the toolchain's version as `node --version` prints it.
|
||||
Node string
|
||||
// Lock is the hex sha256 of the bytes of toolchain/package-lock.json (LockHash).
|
||||
Lock string
|
||||
}
|
||||
|
||||
// LockHash is the text a lock file's bytes stand for in the recipe version.
|
||||
func LockHash(lock []byte) string {
|
||||
sum := sha256.Sum256(lock)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ReadRecipe is the recipe of this build of the module with the lock file read from path. A missing lock is an
|
||||
// error: with no lock there is no toolchain to name.
|
||||
func ReadRecipe(lockPath string) (Recipe, error) {
|
||||
raw, err := os.ReadFile(lockPath)
|
||||
if err != nil {
|
||||
return Recipe{}, fmt.Errorf("the toolchain's lock file cannot be read: %w", err)
|
||||
}
|
||||
return Recipe{Steps: RecipeSteps, Node: RecipeNode, Lock: LockHash(raw)}, nil
|
||||
}
|
||||
|
||||
// Version is the recipe version: a hash of the recipe's own version, the Node version and the lock file's hash, so a
|
||||
// change of any of the three moves it and with it every fingerprint that holds it (ADR 0405 §7, ADR 0465 §2). The
|
||||
// fields are named in the hashed text, so no two recipes share a text.
|
||||
func (r Recipe) Version() string {
|
||||
h := sha256.New()
|
||||
for _, f := range [][2]string{{"steps", r.Steps}, {"node", r.Node}, {"lock", r.Lock}} {
|
||||
h.Write([]byte(f[0] + "=" + f[1] + "\n"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
Reference in New Issue
Block a user