The operator's desktop slows while it builds (shanks at 92%, the Go build runtime at 1650% of 24 processors). The build modules declare cpu-cap (default shanks=50,g14=50,ace=75), which the composer turns into the runtime's quota at idle priority; each step's go, node and buildah commands get GOMAXPROCS and UV_THREADPOOL_SIZE for their part of it, and the status tools show the limits and what the kernel holds the runtime to.
258 lines
9.9 KiB
Go
258 lines
9.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"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
|
|
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
|
|
// the SDK's fetch and merge. A test points them at a local repository (buildstep.AllowFileRemote).
|
|
RemoteBase string
|
|
FetchOptions []buildstep.Option
|
|
Clock func() time.Time
|
|
// Toolchains and Steps are the Put of the states of those names.
|
|
Toolchains, Steps buildstep.Put
|
|
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
|
|
ReadSteps, ReadToolchainsState StateRead
|
|
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
|
|
Pauses buildstep.Put
|
|
ReadPauses StateRead
|
|
// 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)
|
|
// HTTP reads and publishes to the package registry; a client of its own when nil. A test serves its own.
|
|
HTTP *http.Client
|
|
// RegistryFile holds the artifact store's address (host:port), placed by the manifest; Push puts a blob there
|
|
// (buildstep.PushBlob when nil; a test's fake store).
|
|
RegistryFile string
|
|
Push func(ctx context.Context, registry, repository, digest string, body []byte) error
|
|
// Bubblewrap reads the installed bubblewrap's version (ProbeBubblewrap): a step is refused, and the module is
|
|
// 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
|
|
|
|
// Settings is the module's settings file, which holds the node's build capacity (buildstep.CapacitySetting); Capacity,
|
|
// when set, answers it instead (a test).
|
|
Settings string
|
|
Capacity func() int
|
|
|
|
spacesOnce sync.Once
|
|
workspaces *buildstep.Workspaces
|
|
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
|
|
}
|
|
|
|
// guard is the SDK guard for the version the probe just read: the module names no Node version (ADR 0477 §2), so the
|
|
// version wanted is the one installed and the guard refuses only when node cannot be run, writing the installed
|
|
// version to <node>.typescript of the toolchains state every time.
|
|
func (m *Module) guard(r Recipe, installed string, probeErr error) buildstep.Guard {
|
|
return buildstep.Guard{Language: "typescript", Want: installed, Recipe: r.Version(), Node: m.Node, Put: m.reportingPut(),
|
|
Probe: func(context.Context) (string, error) { return installed, probeErr }}
|
|
}
|
|
|
|
// Check reads the installed toolchain, writes <node>.typescript of the toolchains state and answers nil, or the
|
|
// refusal when node cannot be run (buildstep.Guard.Check); another version than another node's is no refusal (ADR 0477
|
|
// §2). It runs at start, before every step, at every health look and every CheckRound.
|
|
func (m *Module) Check(ctx context.Context) error {
|
|
_, err := m.check(ctx)
|
|
return err
|
|
}
|
|
|
|
// check is Check that also answers the version it found installed.
|
|
func (m *Module) check(ctx context.Context) (string, error) {
|
|
if m.Reload != nil {
|
|
r, err := m.Reload()
|
|
if err != nil {
|
|
return "", &buildstep.Refusal{Text: err.Error()}
|
|
}
|
|
m.mu.Lock()
|
|
m.Recipe = r
|
|
m.mu.Unlock()
|
|
}
|
|
installed, err := m.Probe(ctx)
|
|
if err := m.guard(m.recipe(), installed, err).Check(ctx); err != nil {
|
|
return installed, err
|
|
}
|
|
return installed, m.checkBubblewrap(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)
|
|
}
|
|
m.CleanWorkspaces(m.now())
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(CheckRound):
|
|
}
|
|
}
|
|
}
|
|
|
|
// Health is the module's health (the runtime-health tool, ADR 0366): unhealthy only while node cannot be run (or the lock cannot
|
|
// be read), saying why, so the controller raises the node's condition; healthy it names the installed version. It looks again each time it is
|
|
// asked.
|
|
func (m *Module) Health(ctx context.Context) health.Answer {
|
|
installed, err := m.check(ctx)
|
|
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()))
|
|
}
|
|
|
|
// 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 whose node cannot be run ends every
|
|
// step refused with the cause 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
|
|
}
|
|
if err := m.room(); err != nil {
|
|
return nil, err
|
|
}
|
|
defer m.holdArg(args)()
|
|
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
|
|
}
|
|
if err := m.room(); err != nil {
|
|
return nil, err
|
|
}
|
|
defer m.holdArg(args)()
|
|
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(), "installed": installed, "refusing": err != nil,
|
|
"cpu": m.cpuStatus()}
|
|
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 append([]string{"PATH=" + pathOrDefault(), "HOME=" + m.Toolchain.homeOrEmpty(), "LC_ALL=C"}, m.threadEnv()...)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|