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 //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 // Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of // the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444). Emit func(eventType string, body any) error // 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.reportingPut()} } // Check reads the installed toolchain, writes .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 } // ToolchainReported is the event the guard emits after it writes the toolchains key. const ToolchainReported = "toolchain.reported" // reportingPut is the toolchains Put that then emits toolchain.reported with the node and the language. A write that // failed emits nothing; an emit that failed is said and does not refuse the step. func (m *Module) reportingPut() buildstep.Put { return func(key string, value any) (uint64, error) { rev, err := m.Toolchains(key, value) if err != nil || m.Emit == nil { return rev, err } if eerr := m.Emit(ToolchainReported, map[string]string{"node": m.Node, "language": "typescript"}); eerr != nil && m.Say != nil { m.Say("the event %s could not be emitted: %v", ToolchainReported, eerr) } return rev, nil } }