Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2d171f2d2 | ||
|
|
73fa64ea68 | ||
|
|
1a13dbeb17 | ||
|
|
e11caecdad | ||
|
|
7bb9e55d0b | ||
|
|
00037608ae | ||
|
|
cea59428b1 | ||
|
|
5c832f2d19 | ||
|
|
cdebb7d1a5 | ||
|
|
6a803ea5b3 | ||
|
|
9745c1ab31 | ||
|
|
c0c3c3fed4 | ||
|
|
c1449fffe9 | ||
|
|
f873c97db5 | ||
|
|
b0b3d87fe2 | ||
|
|
ba189e6943 |
@@ -148,6 +148,11 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
||||
// rebuild the graph rather than a list of names.
|
||||
Path: result.Path,
|
||||
}
|
||||
// When it was asked, which is what orders it against another build of the same module
|
||||
// (novox/hq 04-ISSUES/219) — not when it was heard.
|
||||
if asked, ok := link.BuildAskedAt(result.ID); ok {
|
||||
kept.Asked = asked
|
||||
}
|
||||
for _, ref := range result.Against {
|
||||
kept.Against = append(kept.Against, catalogue.Recorded(ref))
|
||||
}
|
||||
@@ -409,7 +414,7 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
||||
// Correlated by something the control plane makes, not by the module's name: two builds of one
|
||||
// module can be in flight, and the second answer is not the first one's.
|
||||
request := link.BuildRequest{
|
||||
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
||||
ID: link.NewBuildID(time.Now()),
|
||||
Repository: repository,
|
||||
Path: path,
|
||||
Ref: ref,
|
||||
@@ -526,6 +531,9 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
||||
BuiltFrom: result.Commit, Head: result.Commit,
|
||||
// What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4).
|
||||
Against: kept.Against,
|
||||
// When it was asked, so an older request heard later does not replace a newer one
|
||||
// (novox/hq 04-ISSUES/219).
|
||||
Asked: kept.Asked,
|
||||
}
|
||||
if result.Source != nil && result.Source.Seat != "" {
|
||||
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
|
||||
@@ -544,6 +552,10 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
||||
result.On, result.Repository, short(result.Commit), err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
|
||||
if errors.Is(err, inventory.ErrSuperseded) {
|
||||
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w",
|
||||
result.On, manifest.Module, short(result.Commit), err)
|
||||
}
|
||||
return manifest, kept, err
|
||||
}
|
||||
return manifest, kept, nil
|
||||
@@ -573,7 +585,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
|
||||
defer ask.Close()
|
||||
|
||||
result, err := ask.Submit(ctx, link.BuildRequest{
|
||||
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
||||
ID: link.NewBuildID(time.Now()),
|
||||
Repository: repository, Path: path, Ref: ref,
|
||||
Held: heldBy(ctx), Seats: seatBases(ctx),
|
||||
}, wait)
|
||||
|
||||
@@ -2,9 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -100,3 +103,50 @@ func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) {
|
||||
t.Errorf("a module first built at a commit follows %q, want the default branch", src.Ref)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/219: an older request heard after a newer one is recorded and not registered,
|
||||
// so a push sends what the newer request built.
|
||||
func TestAnOlderBuildHeardLaterDoesNotReplaceTheNewer(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
older := time.Date(2026, 10, 3, 21, 33, 45, 0, time.UTC)
|
||||
newer := time.Date(2026, 10, 3, 21, 51, 57, 0, time.UTC)
|
||||
result := func(asked time.Time, image string) link.BuildResult {
|
||||
manifest, _ := json.Marshal(map[string]any{"module": "postgres", "version": image})
|
||||
return link.BuildResult{ID: link.NewBuildID(asked), Repository: "http://forge.internal:20000/novox/mesh-catalog.git",
|
||||
Path: "modules/postgres", Ref: "main", On: "anchor", Commit: "efff5415", Manifest: manifest,
|
||||
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
||||
}
|
||||
if _, _, err := takeIn(ctx, open.inventory, result(newer, "4bcd5f73")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err := takeIn(ctx, open.inventory, result(older, "0ab07fa9"))
|
||||
if !errors.Is(err, inventory.ErrSuperseded) {
|
||||
t.Fatalf("the older request's outcome was taken in as current: %v", err)
|
||||
}
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := shelf["postgres"].Version; got != "4bcd5f73" {
|
||||
t.Errorf("postgres is %q; want the newer request's 4bcd5f73", got)
|
||||
}
|
||||
if builds, _ := open.inventory.Builds(ctx, "postgres", 5); len(builds) != 2 {
|
||||
t.Errorf("the late build was not recorded: %v", builds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuildIDSaysWhenItWasAsked(t *testing.T) {
|
||||
at := time.Date(2026, 10, 3, 21, 51, 57, 392539762, time.UTC)
|
||||
if got, ok := link.BuildAskedAt(link.NewBuildID(at)); !ok || !got.Equal(at) {
|
||||
t.Errorf("read back %v %v; want %v", got, ok, at)
|
||||
}
|
||||
if got, ok := link.BuildAskedAt("build-1791064317392539762"); !ok || got.Format(time.TimeOnly) != "21:51:57" {
|
||||
t.Errorf("the incident's id reads as %v %v", got, ok)
|
||||
}
|
||||
for _, id := range []string{"b-1", "build-2", "build-", "build-x", ""} {
|
||||
if _, ok := link.BuildAskedAt(id); ok {
|
||||
t.Errorf("%q read as a request time", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -253,25 +254,34 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) {
|
||||
// became of a build nobody was watching.
|
||||
func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
||||
manifest, _, err := takeIn(ctx, b.inv, result)
|
||||
// When it was asked, so a plan takes as its outcome only a build asked for it or after it
|
||||
// (novox/hq 04-ISSUES/219). Zero when the id does not say.
|
||||
asked, _ := link.BuildAskedAt(result.ID)
|
||||
switch {
|
||||
case err != nil && result.Failed != "":
|
||||
fmt.Printf("%s: %v\n", result.ID, err)
|
||||
if result.Module != "" {
|
||||
planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed)
|
||||
planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed, asked)
|
||||
} else {
|
||||
planFailedBuild(ctx, b.open, result)
|
||||
}
|
||||
return nil
|
||||
case errors.Is(err, inventory.ErrSuperseded):
|
||||
// Not a failure: the module is already at what a later request built. A plan that asked
|
||||
// before that later request is answered by it; one that asked after it ignores this.
|
||||
fmt.Printf("%s: %v\n", result.ID, err)
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked)
|
||||
return nil
|
||||
case err != nil:
|
||||
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
|
||||
if manifest.Module != "" {
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, err.Error())
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, err.Error(), asked)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
||||
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||
saysWhenThePolicyActs(ctx, b.inv, manifest.Module)
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "")
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// novox/hq issue 213: for the moment a machine hands its controller over, the container and the
|
||||
// process both run the plan timer on one store. Only the one holding the plans moves them; the other
|
||||
// leaves them alone, and moves them once they are let go.
|
||||
func TestAControllerLeavesThePlansToTheOneHoldingThem(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC()
|
||||
// Every tier done: the next step is the plan's last, and needs nothing but the store.
|
||||
plan := inventory.Plan{ID: "plan-213", Repository: "r", Commit: "abc", Created: now, Updated: now,
|
||||
State: inventory.PlanRolling, Tier: 1, Tiers: [][]string{{"app"}},
|
||||
Modules: map[string]*inventory.PlanModule{"app": {State: "built"}}}
|
||||
if err := open.inventory.SavePlan(ctx, plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The other controller: its own connections to the same store, holding the plans.
|
||||
other, err := inventory.Open(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(other.Close)
|
||||
release, err := other.HoldPlans(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(release) // before the close above: a pool waits for a connection still held
|
||||
|
||||
advancePlans(ctx, open)
|
||||
if p, err := open.inventory.PlanByID(ctx, "plan-213"); err != nil || !p.Open() {
|
||||
t.Fatalf("a controller moved a plan another held: %+v %v", p, err)
|
||||
}
|
||||
|
||||
release()
|
||||
advancePlans(ctx, open)
|
||||
if p, err := open.inventory.PlanByID(ctx, "plan-213"); err != nil || p.State != inventory.PlanDone {
|
||||
t.Fatalf("the plan did not move once it was let go: %+v %v", p, err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -288,8 +289,23 @@ func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) e
|
||||
|
||||
// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and
|
||||
// advances what that completes. Called from the daemon's take-in of every outcome.
|
||||
func planBuilt(ctx context.Context, open *stores, module, commit, failed string) {
|
||||
//
|
||||
// **Only a build asked at or after the plan's ask is its outcome** (novox/hq 04-ISSUES/219). Two
|
||||
// plans a few minutes apart both ask for a module; the earlier plan's build, finishing late, is not
|
||||
// the later plan's answer — it stood on the bases from before the later plan's merge, and taking it
|
||||
// would send machines, and the next tier, what the later merge replaced. asked is zero when the
|
||||
// build's request time is not known, and such an outcome is taken as before.
|
||||
func planBuilt(ctx context.Context, open *stores, module, commit, failed string, asked time.Time) {
|
||||
inv := open.inventory
|
||||
// One controller works the plans at a time (novox/hq issue 213); an outcome waits its turn rather
|
||||
// than write over what the holder is about to save. Not taken, it is still in the build records,
|
||||
// which the holder settles the plan from (issue 214).
|
||||
release, err := inv.HoldPlans(ctx, true)
|
||||
if err != nil {
|
||||
fmt.Printf("plans: %s's outcome is left to the build records: %v\n", module, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
plans, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("plans: cannot read them: %v\n", err)
|
||||
@@ -315,6 +331,9 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string)
|
||||
state = &inventory.PlanModule{}
|
||||
p.Modules[module] = state
|
||||
}
|
||||
if askedBefore(asked, state.AskedAt) {
|
||||
continue
|
||||
}
|
||||
if failed != "" {
|
||||
state.State = "failed"
|
||||
state.Why = failed
|
||||
@@ -333,13 +352,30 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string)
|
||||
fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note)
|
||||
}
|
||||
}
|
||||
advancePlans(ctx, open)
|
||||
advanceHeld(ctx, open)
|
||||
}
|
||||
|
||||
// advancePlans moves every open plan as far as the facts allow: a tier whose modules are all built
|
||||
// and whose gates are applied gives way to the next; the last tier done is the plan done. Called
|
||||
// after every outcome and on a timer, so a plan waiting on a machine's report moves when it comes.
|
||||
//
|
||||
// **One controller at a time** (novox/hq issue 213). A plan is read, changed and saved whole; two
|
||||
// controllers — the old and the new while a machine hands its controller over — would each ask a
|
||||
// tier the other had just asked. Taken without waiting: whoever holds the plans is moving them.
|
||||
func advancePlans(ctx context.Context, open *stores) {
|
||||
release, err := open.inventory.HoldPlans(ctx, false)
|
||||
if err != nil {
|
||||
if !errors.Is(err, inventory.ErrPlansBusy) {
|
||||
fmt.Printf("plans: cannot hold them: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
advanceHeld(ctx, open)
|
||||
}
|
||||
|
||||
// advanceHeld is advancePlans for a caller already holding the plans.
|
||||
func advanceHeld(ctx context.Context, open *stores) {
|
||||
inv := open.inventory
|
||||
plans, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
@@ -549,7 +585,8 @@ func planFailedBuild(ctx context.Context, open *stores, result link.BuildResult)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if repositoryMatches(e.Source.Repository, result.Repository) && e.Source.Path == result.Path {
|
||||
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed)
|
||||
asked, _ := link.BuildAskedAt(result.ID)
|
||||
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed, asked)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -666,6 +703,19 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier)
|
||||
release, err := inv.HoldPlans(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
if p, err = inv.PlanByID(ctx, positionals[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.Open() {
|
||||
return fmt.Errorf("%s is already %s", p.ID, p.State)
|
||||
}
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier)
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -791,6 +841,11 @@ func settleFromRecords(p *inventory.Plan, tier []string, recorded map[string][]i
|
||||
if b.At.Before(*s.AskedAt) {
|
||||
break
|
||||
}
|
||||
// Recorded after the ask and asked before it: an earlier ask's late outcome, not this
|
||||
// one's (novox/hq 04-ISSUES/219).
|
||||
if askedBefore(b.Asked, s.AskedAt) {
|
||||
continue
|
||||
}
|
||||
outcome = &b
|
||||
}
|
||||
if outcome == nil {
|
||||
@@ -812,3 +867,9 @@ func settleFromRecords(p *inventory.Plan, tier []string, recorded map[string][]i
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// askedBefore is whether a build asked at asked was asked before a plan asked for its module — and
|
||||
// so is not that plan's outcome (novox/hq 04-ISSUES/219). False when either time is not known.
|
||||
func askedBefore(asked time.Time, planAsked *time.Time) bool {
|
||||
return !asked.IsZero() && planAsked != nil && asked.Before(*planAsked)
|
||||
}
|
||||
|
||||
@@ -155,6 +155,24 @@ func TestAPlanSettlesAnAskedBuildFromTheRecords(t *testing.T) {
|
||||
t.Errorf("an ask with no record after it was settled: %+v", s)
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/219: a build recorded after the ask but asked before it — an earlier
|
||||
// plan's late outcome — is not this ask's, built or failed.
|
||||
r := inventory.Plan{ID: "plan-3", Tiers: [][]string{{"postgres"}},
|
||||
Modules: map[string]*inventory.PlanModule{"postgres": {State: "asked", AskedAt: &asked}}}
|
||||
late := map[string][]inventory.Build{"postgres": {
|
||||
{ID: "build-old", Commit: "efff5415", Asked: asked.Add(-18 * time.Minute), At: asked.Add(12 * time.Minute)},
|
||||
}}
|
||||
if settleFromRecords(&r, r.Tiers[0], late) || r.Modules["postgres"].State != "asked" {
|
||||
t.Errorf("an earlier ask's late outcome settled this ask: %+v", r.Modules["postgres"])
|
||||
}
|
||||
// Newest heard first: the earlier ask's late outcome, then this ask's own, heard before it.
|
||||
late["postgres"] = append(late["postgres"], inventory.Build{ID: "build-mine", Commit: "4bcd5f73",
|
||||
Asked: asked.Add(time.Second), At: asked.Add(5 * time.Minute)})
|
||||
if !settleFromRecords(&r, r.Tiers[0], late) || r.Modules["postgres"].State != "built" ||
|
||||
r.Modules["postgres"].Commit != "4bcd5f73" {
|
||||
t.Errorf("this ask's own outcome, heard before the earlier ask's, did not settle it: %+v", r.Modules["postgres"])
|
||||
}
|
||||
|
||||
// A failure recorded after the ask fails the plan, as hearing it would have.
|
||||
q := inventory.Plan{ID: "plan-2", Tiers: [][]string{{"x"}},
|
||||
Modules: map[string]*inventory.PlanModule{"x": {State: "asked", AskedAt: &asked}}}
|
||||
|
||||
@@ -315,6 +315,13 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
for _, e := range moved {
|
||||
movedNames = append(movedNames, e.Manifest.Module)
|
||||
}
|
||||
// Written and its first tier asked as one act on the plans (novox/hq issue 213): a timer on
|
||||
// another controller reading it between the two would ask the tier again.
|
||||
release, err := inv.HoldPlans(ctx, true)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
defer release()
|
||||
plan := planOfMerge(m, movedNames, edges)
|
||||
if hasCycle(plan.Tiers, edges) {
|
||||
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
||||
|
||||
+106
-3
@@ -215,7 +215,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
||||
for _, a := range artifacts {
|
||||
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
||||
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, say)
|
||||
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, say)
|
||||
if err != nil {
|
||||
say("artifact", "%s FAILED: %v", a.Name, err)
|
||||
return Result{}, err
|
||||
@@ -443,7 +443,7 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
|
||||
|
||||
func one(ctx context.Context, run Runner, publish Publisher,
|
||||
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
|
||||
held map[string]string, npmrc string, seats map[string]string,
|
||||
held map[string]string, npmrc string, registry Npmrc, seats map[string]string,
|
||||
say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||
|
||||
switch a.Kind {
|
||||
@@ -582,6 +582,11 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
"holds no copy of it. Build %s first",
|
||||
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
|
||||
}
|
||||
// The module's own packages first, where the compiler and the bundler resolve them from
|
||||
// (dependencies.go); nothing at all for a module whose package.json names only the SDK.
|
||||
if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err)
|
||||
}
|
||||
say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base)
|
||||
compiled, err := compile(ctx, run, tree, chain, base, a)
|
||||
if err != nil {
|
||||
@@ -593,6 +598,12 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err)
|
||||
}
|
||||
if chain.Bundler != "" {
|
||||
say("bundle", "bundling each entrypoint into one file")
|
||||
if compiled, err = bundled(ctx, run, tree, chain, base, a, launchers); err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: bundling %s failed: %w", module, a.Name, err)
|
||||
}
|
||||
}
|
||||
say("bundle", "compiled, packing")
|
||||
body, err := pack(compiled)
|
||||
if err != nil {
|
||||
@@ -974,7 +985,7 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if chain.Dependencies != "" {
|
||||
if chain.Dependencies != "" && chain.Bundler == "" {
|
||||
// **What the bundle runs with, from the image it was compiled in** (Toolchain.Dependencies).
|
||||
// A second run in the same image rather than a shell wrapped around the compiler: the
|
||||
// compile line stays a plain command a reader can run by hand, and the copy is one more
|
||||
@@ -1224,3 +1235,95 @@ func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[str
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bundledSuffix is where a bundle's one-file output is written, beside what the compiler wrote.
|
||||
const bundledSuffix = ".bundled"
|
||||
|
||||
// bundled makes every entrypoint and every launcher of a compiled bundle ONE file, in the toolchain
|
||||
// image's bundler, and answers the directory to pack (novox/hq ADR 0193).
|
||||
//
|
||||
// **What a launched bundle runs is what it imports, and nothing else.** Every served bundle is its
|
||||
// own process, so it carries its own copy of the SDK and its own dependencies inlined — the
|
||||
// toolchain's whole node_modules no longer travels in every bundle. An entrypoint a process runs by
|
||||
// name (`node daemon/index.js`) is bundled in place under its own name; a launcher keeps its name
|
||||
// and its first line, and stays executable. A package the bundler cannot inline is named by the
|
||||
// artifact (`external`), kept as an import, and only then is the toolchain's runtime directory
|
||||
// copied beside the files. CommonJS inlined into an ES module still finds `require`.
|
||||
func bundled(ctx context.Context, run Runner, tree string, chain Toolchain, base string,
|
||||
a catalogue.Artifact, launchers map[string]string) (string, error) {
|
||||
const within = "/app/modules/module"
|
||||
out, final := Out(a.Name), Out(a.Name)+bundledSuffix
|
||||
if err := os.RemoveAll(filepath.Join(tree, final)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(tree, final), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
common := []string{"--bundle", "--platform=node", "--format=esm", "--target=node22",
|
||||
"--outbase=" + out, "--outdir=" + final, "--log-level=warning",
|
||||
"--banner:js=import { createRequire as __meshRequire } from 'node:module'; const require = __meshRequire(import.meta.url);"}
|
||||
for _, x := range a.External {
|
||||
common = append(common, "--external:"+x)
|
||||
}
|
||||
var plain []string
|
||||
for _, e := range a.Entrypoints {
|
||||
if strings.HasSuffix(e, ".js") {
|
||||
plain = append(plain, out+"/"+e)
|
||||
}
|
||||
}
|
||||
var launch []string
|
||||
for _, l := range sortedValues(launchers) {
|
||||
launch = append(launch, out+"/"+l)
|
||||
}
|
||||
// Refused by name in an image that predates the bundler, as the dependencies copy is: a bundle
|
||||
// packed without it would carry nothing it imports. Run as itself: npm installs esbuild's native
|
||||
// binary in place of its script, which `node` cannot run.
|
||||
guard := `test -x "$0" || { echo "the toolchain image carries no bundler at $0: it predates one-file bundles, rebuild mesh-tools first" >&2; exit 1; }; exec "$0" "$@"`
|
||||
step := func(entries []string, extra ...string) error {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
invocation := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base,
|
||||
"sh", "-c", guard, chain.Bundler}
|
||||
invocation = append(invocation, entries...)
|
||||
invocation = append(invocation, common...)
|
||||
invocation = append(invocation, extra...)
|
||||
_, err := run(ctx, tree, "docker", invocation...)
|
||||
return err
|
||||
}
|
||||
if err := step(plain); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := step(launch, "--out-extension:.js=.mjs"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Plain `.js` output is an ES module; said once, as the runtime directory used to say it.
|
||||
if err := os.WriteFile(filepath.Join(tree, final, "package.json"), []byte(`{"type":"module","private":true}`+"\n"), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, l := range launchers {
|
||||
path := filepath.Join(tree, final, filepath.FromSlash(l))
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if err := os.Chmod(path, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(a.External) > 0 && chain.Dependencies != "" {
|
||||
copying := []string{"run", "--rm", "--volume", tree + ":" + within, "--workdir", within, base,
|
||||
"sh", "-c", `cp -a "$0/node_modules" "$1/"`, chain.Dependencies, final}
|
||||
if _, err := run(ctx, tree, "docker", copying...); err != nil {
|
||||
return "", fmt.Errorf("copying the packages %s keeps external: %w", a.Name, err)
|
||||
}
|
||||
}
|
||||
return filepath.Join(tree, final), nil
|
||||
}
|
||||
|
||||
func sortedValues(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for _, v := range m {
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -83,25 +83,49 @@ func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) {
|
||||
t.Fatalf("the bundle was not pinned: %v", got.Manifest.Resources[0])
|
||||
}
|
||||
|
||||
// **And what it runs with, from the image it was compiled in** (novox/hq to-be 38 WP3). A
|
||||
// second run in the same toolchain image copies the toolchain's runtime directory — the
|
||||
// `"type": "module"` package.json and the pruned node_modules — into the output's root, and
|
||||
// refuses by name when the image carries none rather than packing a bundle that starts nowhere.
|
||||
var copied string
|
||||
// **One file per entrypoint and launcher, in the toolchain's bundler** (novox/hq ADR 0193). A
|
||||
// second run in the same toolchain image bundles each into the artifact's bundled output, the SDK
|
||||
// inlined, refusing by name in an image that predates the bundler; and the toolchain's
|
||||
// node_modules is no longer copied into a bundle that keeps nothing external.
|
||||
var bundling []string
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "/app/runtime") {
|
||||
copied = line
|
||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "esbuild") {
|
||||
bundling = append(bundling, line)
|
||||
}
|
||||
}
|
||||
if copied == "" {
|
||||
t.Fatalf("the bundle's dependencies were not copied in after the compile:\n%s", strings.Join(r.ran, "\n"))
|
||||
if len(bundling) != 2 {
|
||||
t.Fatalf("want one bundling run for the entrypoints and one for the launchers:\n%s", strings.Join(r.ran, "\n"))
|
||||
}
|
||||
if !strings.Contains(copied, "mesh-tools/build@sha256:") || !strings.Contains(copied, "predates") ||
|
||||
!strings.Contains(copied, Out("code")) {
|
||||
t.Fatalf("the copy does not run in the same toolchain, refuse an older image by name, or land in the artifact's output: %s", copied)
|
||||
for _, want := range []string{"mesh-tools/build@sha256:", "predates one-file bundles", "--bundle", "--format=esm",
|
||||
"--platform=node", "--outdir=" + Out("code") + ".bundled", Out("code") + "/index.js"} {
|
||||
if !strings.Contains(bundling[0], want) {
|
||||
t.Errorf("the entrypoints' bundling lacks %q: %s", want, bundling[0])
|
||||
}
|
||||
}
|
||||
if strings.Index(strings.Join(r.ran, "\n"), "--outDir") > strings.Index(strings.Join(r.ran, "\n"), "/app/runtime") {
|
||||
t.Fatal("the dependencies were copied before the compile wrote its output")
|
||||
if !strings.Contains(bundling[1], Out("code")+"/index.serve.mjs") || !strings.Contains(bundling[1], "--out-extension:.js=.mjs") {
|
||||
t.Errorf("the launcher is not bundled under its own name: %s", bundling[1])
|
||||
}
|
||||
if strings.Contains(strings.Join(r.ran, "\n"), "/app/runtime") {
|
||||
t.Errorf("the toolchain's node_modules was copied into a bundle that keeps nothing external:\n%s", strings.Join(r.ran, "\n"))
|
||||
}
|
||||
if strings.Index(strings.Join(r.ran, "\n"), "--outDir") > strings.Index(strings.Join(r.ran, "\n"), "esbuild") {
|
||||
t.Fatal("the bundler ran before the compile wrote its output")
|
||||
}
|
||||
}
|
||||
|
||||
// A bundle naming packages it keeps external is bundled with them as imports, and carries the
|
||||
// toolchain's node_modules for them — the one case it still does.
|
||||
func TestABundleKeepingAPackageExternalCarriesTheToolchainsModules(t *testing.T) {
|
||||
manifest := strings.Replace(aBundle, `"entrypoints":["index.js"]`, `"entrypoints":["index.js"],"external":["sharp"]`, 1)
|
||||
r, workspace := aRepository(t, manifest, map[string]string{"index.ts": "console.log(1)"})
|
||||
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
||||
if _, err := Build(context.Background(), compiling{r}.run, r,
|
||||
"https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all := strings.Join(r.ran, "\n")
|
||||
if !strings.Contains(all, "--external:sharp") || !strings.Contains(all, "/app/runtime") {
|
||||
t.Errorf("an external package was not kept as an import with the toolchain's modules beside it:\n%s", all)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module's own packages, installed before its bundle is compiled, so the bundler inlines them.
|
||||
//
|
||||
// **A bundle could only import what the toolchain happened to carry.** The compiler and the bundler
|
||||
// resolve an import by walking up from the module's source: the module's own directory first, then
|
||||
// the toolchain image's node_modules. Nothing ever put anything in the first, so a module needing a
|
||||
// database driver (`pg`, `mongodb`, `mssql`) could not be a bundle at all, and kept a container whose
|
||||
// recipe installed it by hand (novox/hq ADR 0198 §4: "the backend's own driver inside the bundle").
|
||||
// Now the module's `package.json` says what it depends on, as any Node package does, and the build
|
||||
// installs exactly that into the module's own directory before compiling.
|
||||
//
|
||||
// **The SDK the toolchain carries is the one a bundle is built with, whatever the module says**
|
||||
// (novox/hq issue 212: the toolchain is rebuilt on every SDK release and every bundle after it). A
|
||||
// module's `package.json` names `@novox/mesh-sdk` with a range — it has to, to type-check on a
|
||||
// workstation — and installing that range would shadow the toolchain's copy for this module alone:
|
||||
// one module compiled against an older SDK than its neighbours, chosen by a caret nobody re-reads.
|
||||
// So the SDK is taken out of what is installed (and never fetched), and any copy something else
|
||||
// pulls in is removed afterwards; every import of it resolves past the module's node_modules to the
|
||||
// toolchain's. A module therefore cannot pin a different SDK, by design: the toolchain is the pin.
|
||||
//
|
||||
// **Correctness before speed.** Every build installs afresh into a fresh clone, from the lockfile
|
||||
// when the module has one (`npm ci`, exact) and from its ranges otherwise; nothing installed is kept
|
||||
// between builds. What is shared is npm's own download cache, a named volume, which is
|
||||
// content-addressed and verified by integrity on every read — it saves the network, never the
|
||||
// install. Install scripts do not run: the build node runs nobody's postinstall, and what a script
|
||||
// would build natively could not be inlined into one file anyway.
|
||||
|
||||
// sdkPackage is the package a TypeScript bundle's launcher serves through, and the one package a
|
||||
// module's own dependencies never supply (above).
|
||||
const sdkPackage = "@novox/mesh-sdk"
|
||||
|
||||
// npmCache is the named volume npm's download cache lives in across builds on one build node.
|
||||
const npmCache = "mesh-builder-npm-cache"
|
||||
|
||||
// ownDependencies is what a module's package.json depends on beyond the SDK, sorted; nothing when
|
||||
// the module has no package.json or depends on nothing else — which builds exactly as before.
|
||||
func ownDependencies(tree string) ([]string, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(tree, "package.json"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p struct {
|
||||
Dependencies map[string]string `json:"dependencies"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return nil, fmt.Errorf("the module's package.json is not JSON: %w", err)
|
||||
}
|
||||
var names []string
|
||||
for name := range p.Dependencies {
|
||||
if name != sdkPackage {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// installSteps is the script run inside the toolchain image, from the module's own directory ($0).
|
||||
// It works in a scratch copy so the module's package.json and lockfile are never rewritten, takes
|
||||
// the SDK out of what is installed, installs production dependencies only, removes any copy of the
|
||||
// SDK something pulled in, and puts the result at the module's node_modules.
|
||||
const installSteps = `set -e
|
||||
work="$(mktemp -d)"
|
||||
cp "$0/package.json" "$work/"
|
||||
if [ -f "$0/package-lock.json" ]; then cp "$0/package-lock.json" "$work/"; fi
|
||||
cd "$work"
|
||||
node -e '
|
||||
const fs = require("fs"), sdk = process.argv[1];
|
||||
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||
for (const k of ["dependencies", "peerDependencies", "optionalDependencies"]) if (p[k]) delete p[k][sdk];
|
||||
delete p.devDependencies; delete p.scripts;
|
||||
fs.writeFileSync("package.json", JSON.stringify(p));
|
||||
' "$1"
|
||||
shift
|
||||
if [ -f package-lock.json ]; then
|
||||
npm ci --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund "$@"
|
||||
else
|
||||
npm install --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund --no-package-lock "$@"
|
||||
fi
|
||||
find node_modules -depth -type d -path "*/node_modules/@novox/mesh-sdk" -exec rm -rf {} +
|
||||
rm -rf "$0/node_modules"
|
||||
cp -a node_modules "$0/node_modules"
|
||||
`
|
||||
|
||||
// installOwn installs a TypeScript module's own production dependencies into its directory, in the
|
||||
// toolchain image, before the compile — or does nothing at all for a module that has none.
|
||||
func installOwn(ctx context.Context, run Runner, tree string, chain Toolchain, base string,
|
||||
registry Npmrc, say func(step, format string, args ...any)) error {
|
||||
if chain.Language != "typescript" {
|
||||
return nil
|
||||
}
|
||||
deps, err := ownDependencies(tree)
|
||||
if err != nil || len(deps) == 0 {
|
||||
return err
|
||||
}
|
||||
scoped := strings.TrimSpace(registry.Scope)
|
||||
if !registry.Enabled() {
|
||||
// **No registry, no scoped package.** Without the mesh's registry a scoped name resolves on
|
||||
// the public one, where anybody may have published it: a dependency that installs is not
|
||||
// the dependency the module meant.
|
||||
for _, d := range deps {
|
||||
if strings.HasPrefix(d, "@novox/") {
|
||||
return fmt.Errorf("the module depends on %s, and this build knows no package registry "+
|
||||
"for its scope; it would resolve from the public registry, which is not where the "+
|
||||
"mesh publishes it", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
const within = "/app/modules/module"
|
||||
invocation := []string{"run", "--rm",
|
||||
"--volume", tree + ":" + within,
|
||||
"--volume", npmCache + ":/root/.npm",
|
||||
"--workdir", within}
|
||||
var flags []string
|
||||
if registry.Enabled() {
|
||||
// The registry is reached where the binding says it is, which may be this machine's own
|
||||
// loopback — the reason an image build that resolves packages runs on the host network too.
|
||||
invocation = append(invocation, "--network", "host")
|
||||
reg := strings.TrimSpace(registry.Registry)
|
||||
if !strings.HasSuffix(reg, "/") {
|
||||
reg += "/"
|
||||
}
|
||||
flags = append(flags, "--"+scoped+":registry="+reg)
|
||||
}
|
||||
invocation = append(invocation, base, "sh", "-c", installSteps, within, sdkPackage)
|
||||
invocation = append(invocation, flags...)
|
||||
say("bundle", "installing the module's own packages: %s", strings.Join(deps, ", "))
|
||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||
return fmt.Errorf("installing the module's own packages (%s): %w", strings.Join(deps, ", "), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module's own packages (dependencies.go): installed into its own directory, in the toolchain,
|
||||
// before the compile, so the bundler inlines them — the SDK always the toolchain's.
|
||||
|
||||
func buildWithPackageJSON(t *testing.T, pkg string, extra map[string]string, registry Npmrc) (*recorded, error) {
|
||||
t.Helper()
|
||||
files := map[string]string{"index.ts": "console.log(1)"}
|
||||
if pkg != "" {
|
||||
files["package.json"] = pkg
|
||||
}
|
||||
for k, v := range extra {
|
||||
files[k] = v
|
||||
}
|
||||
r, workspace := aRepository(t, aBundle, files)
|
||||
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
||||
_, err := Build(context.Background(), compiling{r}.run, r,
|
||||
"https://forge.invalid/greeter.git", "", "", workspace, held, registry, GitCredential{}, nil)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func installs(r *recorded) []string {
|
||||
var out []string
|
||||
for _, line := range r.ran {
|
||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "npm ci") {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func compileIndex(r *recorded) int {
|
||||
for i, line := range r.ran {
|
||||
if strings.Contains(line, "--outDir") {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestAModulesOwnPackagesAreInstalledInTheToolchainBeforeTheCompile(t *testing.T) {
|
||||
r, err := buildWithPackageJSON(t, `{"type":"module","dependencies":{"@novox/mesh-sdk":"^0.1.0","pg":"^8"},"devDependencies":{"typescript":"^5"}}`,
|
||||
nil, Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := installs(r)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("want one install of the module's own packages:\n%s", strings.Join(r.ran, "\n"))
|
||||
}
|
||||
line := got[0]
|
||||
for _, want := range []string{
|
||||
"mesh-tools/build@sha256:", // in the toolchain image
|
||||
":/app/modules/module", // into the module's own directory
|
||||
"--workdir /app/modules/module", //
|
||||
npmCache + ":/root/.npm", // npm's verified download cache, and only that
|
||||
"--omit=dev", "--ignore-scripts", // production packages, no build-node scripts
|
||||
"npm ci", "npm install", "--no-package-lock", // the lockfile when there is one, else the ranges
|
||||
"--@novox:registry=https://forge.invalid/api/packages/novox/npm/", // the scope from the mesh's registry
|
||||
"--network host",
|
||||
"@novox/mesh-sdk", // named, to be taken out of what is installed
|
||||
} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Errorf("the install lacks %q:\n%s", want, line)
|
||||
}
|
||||
}
|
||||
// The SDK is the toolchain's: never installed from the module's range, and any copy removed.
|
||||
if !strings.Contains(line, `delete p[k][sdk]`) || !strings.Contains(line, `-path "*/node_modules/@novox/mesh-sdk" -exec rm -rf`) {
|
||||
t.Errorf("the module's own SDK range could shadow the toolchain's SDK:\n%s", line)
|
||||
}
|
||||
if i, c := strings.Index(strings.Join(r.ran, "\n"), "npm ci"), compileIndex(r); c < 0 ||
|
||||
i > strings.Index(strings.Join(r.ran, "\n"), "--outDir") {
|
||||
t.Fatalf("the install did not run before the compile:\n%s", strings.Join(r.ran, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// **A module with nothing beyond the SDK builds exactly as before**: the same commands, no install.
|
||||
func TestAModuleDependingOnlyOnTheSDKBuildsExactlyAsBefore(t *testing.T) {
|
||||
without, err := buildWithPackageJSON(t, "", nil, Npmrc{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, pkg := range []string{
|
||||
`{"type":"module","dependencies":{"@novox/mesh-sdk":"^0.1.0"},"devDependencies":{"typescript":"^5"}}`,
|
||||
`{"type":"module"}`,
|
||||
} {
|
||||
with, err := buildWithPackageJSON(t, pkg, map[string]string{"package-lock.json": "{}"}, Npmrc{Scope: "@novox", Registry: "https://forge.invalid/npm/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(strings.Join(with.ran, "\n"), "npm ") {
|
||||
t.Fatalf("a module depending on nothing but the SDK ran npm:\n%s", strings.Join(with.ran, "\n"))
|
||||
}
|
||||
if len(with.ran) != len(without.ran) {
|
||||
t.Fatalf("a module depending only on the SDK built differently from one with no package.json:\n%s\n---\n%s",
|
||||
strings.Join(with.ran, "\n"), strings.Join(without.ran, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without the mesh's registry a scoped package would resolve on the public one: refused by name.
|
||||
func TestAScopedPackageWithNoRegistryIsRefused(t *testing.T) {
|
||||
r, err := buildWithPackageJSON(t, `{"dependencies":{"@novox/mesh-sdk":"^0.1.0","@novox/other":"^1"}}`, nil, Npmrc{})
|
||||
if err == nil || !strings.Contains(err.Error(), "@novox/other") {
|
||||
t.Fatalf("a scoped package was installed with no registry for its scope: %v", err)
|
||||
}
|
||||
if strings.Contains(strings.Join(r.ran, "\n"), "--outDir") {
|
||||
t.Fatal("the compile ran after the refusal")
|
||||
}
|
||||
// A public package installs without one, from the public registry and nothing else.
|
||||
r, err = buildWithPackageJSON(t, `{"dependencies":{"mssql":"^11"}}`, nil, Npmrc{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := installs(r); len(got) != 1 || strings.Contains(got[0], ":registry=") || strings.Contains(got[0], "--network host") {
|
||||
t.Fatalf("a public package's install: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnreadablePackageJSONIsRefusedByName(t *testing.T) {
|
||||
_, err := buildWithPackageJSON(t, `{"dependencies":`, nil, Npmrc{})
|
||||
if err == nil || !strings.Contains(err.Error(), "package.json") {
|
||||
t.Fatalf("a broken package.json was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -200,3 +200,23 @@ func TestWhatABuildReadIsTheRepositoriesItsRecipesName(t *testing.T) {
|
||||
t.Fatal("a module whose recipes name no other repository read one")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/212: a toolchain stands on the SDK's published package, and is built with the
|
||||
// exact version the mesh published — an argument that changes when the SDK does, so a rebuild after
|
||||
// a release never reuses an install of the version before it.
|
||||
func TestAPackageTheMeshPublishedIsPassedByItsExactVersion(t *testing.T) {
|
||||
manifest := catalogue.Manifest{
|
||||
Module: "mesh-tools",
|
||||
Build: &catalogue.Build{
|
||||
On: []catalogue.BuildsOn{{Arg: "MESH_SDK", Module: "mesh-sdk", Artifact: "lib"}},
|
||||
},
|
||||
}
|
||||
held := map[string]string{"mesh-sdk/lib": "@novox/mesh-sdk@0.1.6"}
|
||||
args, resolved, err := standingOn(context.Background(), manifest, held, noMirror)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(args) != "[--build-arg MESH_SDK=@novox/mesh-sdk@0.1.6]" || fmt.Sprint(resolved) != "[@novox/mesh-sdk@0.1.6]" {
|
||||
t.Errorf("the package was passed as %v, recorded as %v", args, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +67,23 @@ type Toolchain struct {
|
||||
// `package.json` saying `"type": "module"` — Node reads a bare `.js` as CommonJS otherwise, so a
|
||||
// bundle with its dependencies and without that line still fails to start — and the pruned,
|
||||
// production-only node_modules the runtime itself ships with: the SDK's and the runtime's
|
||||
// dependencies, and nothing module-specific yet (novox/hq ADR 0188 §5: a skeleton; a module's
|
||||
// own npm dependencies are a later step). Empty for a language whose bundle carries its own —
|
||||
// dependencies, and nothing module-specific (a module's own npm dependencies are installed into
|
||||
// its own directory before the compile and inlined by the bundler: dependencies.go). Empty for a
|
||||
// language whose bundle carries its own —
|
||||
// a Go binary is static, a Python bundle is installed with its dependencies.
|
||||
//
|
||||
// A toolchain image without the directory fails the build by name rather than packing a bundle
|
||||
// that starts nowhere: the image predates this and must be rebuilt first.
|
||||
//
|
||||
// *Since the bundler (below):* copied only for a bundle that names packages it keeps external,
|
||||
// which cannot be inlined; a bundle with none carries no node_modules at all.
|
||||
Dependencies string
|
||||
// Bundler is the bundler inside the toolchain image that makes each compiled entrypoint and each
|
||||
// launcher ONE self-contained file (novox/hq ADR 0193): every served bundle is its own process
|
||||
// now, so each carries its own copy of what it imports — the SDK included — and nothing else.
|
||||
// A bundle shrinks from the toolchain's whole node_modules to the code it runs. Empty for a
|
||||
// language whose build is already one file.
|
||||
Bundler string
|
||||
// SystemStamp is the variable this language's linker fills with the artifact's declared system,
|
||||
// for a language whose binaries are pinned to one at link time (novox/hq ADR 0005).
|
||||
//
|
||||
@@ -139,6 +149,7 @@ var toolchains = []Toolchain{
|
||||
Unit: UnitSources,
|
||||
SourceExt: ".ts",
|
||||
Dependencies: "/app/runtime",
|
||||
Bundler: "/app/node_modules/esbuild/bin/esbuild",
|
||||
},
|
||||
{
|
||||
Language: "go",
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 213 (ADR 0188 §1, §3): a module's own Go service is a bundle the host runs as a
|
||||
// process, not an image. Each test holds one thing that had to change in the composer for the
|
||||
// controller to be declared that way.
|
||||
|
||||
const aServiceDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
|
||||
// aServiceModule is the controller's shape in miniature: it answers tools of its own, its code is a
|
||||
// Go bundle a process runs as an account it declares, its secrets belong to that account, it
|
||||
// prepares its state, and its process replaces the container it used to run as.
|
||||
func aServiceModule(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
raw := `{
|
||||
"module": "svc", "version": "1", "tools": ["status"], "prepares": true,
|
||||
"own-secrets": {"store": "${dir:state}/store"},
|
||||
"secrets-owner": "svc",
|
||||
"resources": [
|
||||
{"id": "state", "type": "directory", "mode": "0700", "place": "mesh", "owner": "svc"},
|
||||
{"id": "service", "type": "process", "name": "svc", "artifact": "code",
|
||||
"run": ["./svc", "serve"], "user": "svc", "replaces": ["server"],
|
||||
"env": {"SVC_STORE_FILE": "${dir:state}/store", "SVC_STORE_PORT": "${seat:mesh-store:5432}"}},
|
||||
{"id": "account", "type": "user", "name": "svc", "shell": "/usr/bin/nologin", "home": "/var/lib/svc"}
|
||||
],
|
||||
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
|
||||
"from": "cmd/svc", "binary": "svc"}]}
|
||||
}`
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("the service's manifest is refused: %v", err)
|
||||
}
|
||||
resolved, err := m.Resolve([]Built{{Name: "code", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "svc/code@" + aServiceDigest, Digest: aServiceDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func composeTheService(t *testing.T, with Rendering) []map[string]any {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{"svc": {"store": "sealed-store"}}
|
||||
with.ArtifactStore = "anchor.internal:5100"
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{aServiceModule(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatalf("the service does not compose: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func indexOf(out []map[string]any, id string) int {
|
||||
for i, r := range out {
|
||||
if r["id"] == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// A module that declares tools has every bundle served by the node's runtime unless it says
|
||||
// otherwise — and the controller declares the verbs it answers as tools. Its service bundle is run
|
||||
// by its own process; launched a second time by the runtime it would be a second controller
|
||||
// pretending to be an MCP server.
|
||||
func TestABundleItsOwnProcessRunsIsNotServedByTheRuntime(t *testing.T) {
|
||||
m := aServiceModule(t)
|
||||
if len(m.Bundles) != 1 {
|
||||
t.Fatalf("the service's bundle was not kept: %+v", m.Bundles)
|
||||
}
|
||||
if loads := m.Bundles[0].Loads; len(loads) != 0 {
|
||||
t.Fatalf("the runtime would launch the service's own bundle as tools: %v", loads)
|
||||
}
|
||||
// And a bundle no resource runs still is served, as a module declaring tools always had it.
|
||||
tools := Manifest{Module: "t", Version: "1", Tools: []string{"x"},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/t"}}}}
|
||||
resolved, err := tools.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "t/tools@" + aServiceDigest, Digest: aServiceDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loads := resolved.Bundles[0].Loads; len(loads) != 1 || loads[0] != "t" {
|
||||
t.Fatalf("a tools bundle nothing runs is no longer served: %v", loads)
|
||||
}
|
||||
}
|
||||
|
||||
// The account is created before anything is given to it. Its secrets are mesh-computed and so
|
||||
// placed before the module's own resources; given to a user the machine did not have yet, they were
|
||||
// refused on the first apply and the process started without them.
|
||||
func TestAModulesAccountComesBeforeWhatBelongsToIt(t *testing.T) {
|
||||
out := composeTheService(t, Rendering{})
|
||||
account, secret := indexOf(out, "svc.account"), indexOf(out, "svc."+NeedID("store"))
|
||||
if account < 0 || secret < 0 {
|
||||
t.Fatalf("the account or the secret is missing: %v", out)
|
||||
}
|
||||
if account > secret {
|
||||
t.Fatalf("the secret owned by svc is written before svc exists: account at %d, secret at %d",
|
||||
account, secret)
|
||||
}
|
||||
if owner := out[secret]["owner"]; owner != "svc" {
|
||||
t.Errorf("the secret belongs to %v, not the account its process runs as", owner)
|
||||
}
|
||||
}
|
||||
|
||||
// The process is the module's program; its preparation is the same program asked to prepare, as a
|
||||
// step before it — with the same account and environment, and handing nothing over.
|
||||
func TestAProcessIsPreparedByItsOwnProgram(t *testing.T) {
|
||||
out := composeTheService(t, Rendering{})
|
||||
step, process := indexOf(out, "svc.service-prepare"), indexOf(out, "svc.service")
|
||||
if step < 0 || process < 0 || step > process {
|
||||
t.Fatalf("the preparation is not a step before the process (%d, %d): %v", step, process, out)
|
||||
}
|
||||
s := out[step]
|
||||
if s["type"] != "process" || s["run-once"] != true || s["name"] != "svc-prepare" {
|
||||
t.Errorf("the preparation is not a run-once process: %v", s)
|
||||
}
|
||||
if run, _ := json.Marshal(s["run"]); string(run) != `["./svc","prepare"]` {
|
||||
t.Errorf("the preparation runs %s", run)
|
||||
}
|
||||
if s["user"] != "svc" || s["source"] != out[process]["source"] {
|
||||
t.Errorf("the preparation does not run the same bundle as the same account: %v", s)
|
||||
}
|
||||
if env, _ := s["env"].(map[string]any); env["SVC_STORE_FILE"] == nil {
|
||||
t.Errorf("the preparation is not given the process's environment: %v", s["env"])
|
||||
}
|
||||
if _, has := s["replaces"]; has {
|
||||
t.Errorf("the preparation would hand over what the process replaces: %v", s)
|
||||
}
|
||||
if _, has := s["args"]; has {
|
||||
t.Errorf("the preparation carries a container's args: %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// What the process replaces is named as the host recorded it, `<module>.<id>`; unprefixed, the host
|
||||
// matches nothing and removes the container first, as before.
|
||||
func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) {
|
||||
out := composeTheService(t, Rendering{})
|
||||
p := out[indexOf(out, "svc.service")]
|
||||
if got, _ := json.Marshal(p["replaces"]); string(got) != `["svc.server"]` {
|
||||
t.Fatalf("the process replaces %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) {
|
||||
for what, resource := range map[string]string{
|
||||
"a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`,
|
||||
"a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`,
|
||||
"something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`,
|
||||
"another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`,
|
||||
"not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`,
|
||||
} {
|
||||
raw := `{"module":"m","version":"1","resources":[` + resource + `]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") {
|
||||
t.Errorf("replaces on %s was accepted: %v", what, err)
|
||||
}
|
||||
}
|
||||
ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}`
|
||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||
t.Errorf("a process replacing what its module no longer declares was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
// build compare equal.
|
||||
out.Bundles = nil
|
||||
if m.Build != nil {
|
||||
run := runByAResource(m)
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind != ArtifactBundle {
|
||||
continue
|
||||
@@ -84,8 +85,13 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
made := by[a.Name]
|
||||
// What the runtime loads: what the artifact said, else every entrypoint of a module
|
||||
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
|
||||
//
|
||||
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
|
||||
// A process the host runs is the module's service, not its tools: the controller declares
|
||||
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
|
||||
// have been launched a second time by the node's runtime, as an MCP child it is not.
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 {
|
||||
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||
@@ -214,6 +220,11 @@ func (b *Build) problems(module string) []string {
|
||||
// A bundle's source is the module's own directory by definition, and what it needs to say
|
||||
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
||||
// has not said.
|
||||
if len(a.External) > 0 && (a.Kind != ArtifactBundle || a.Language != "typescript") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+
|
||||
"one file with some kept out (novox/hq ADR 0193)", module, a.Name))
|
||||
}
|
||||
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
|
||||
@@ -448,6 +459,18 @@ func BinaryOf(a Artifact) string {
|
||||
return a.Name
|
||||
}
|
||||
|
||||
// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
|
||||
// a step, an archive that unpacks it — by name.
|
||||
func runByAResource(m Manifest) map[string]bool {
|
||||
named := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if a, ok := r["artifact"].(string); ok && a != "" {
|
||||
named[a] = true
|
||||
}
|
||||
}
|
||||
return named
|
||||
}
|
||||
|
||||
// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq
|
||||
// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says
|
||||
// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or
|
||||
@@ -458,12 +481,7 @@ func undeliveredBundles(m Manifest) []string {
|
||||
if m.Build == nil || m.Module == RuntimeModule {
|
||||
return nil
|
||||
}
|
||||
named := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if a, ok := r["artifact"].(string); ok && a != "" {
|
||||
named[a] = true
|
||||
}
|
||||
}
|
||||
named := runByAResource(m)
|
||||
var problems []string
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 {
|
||||
|
||||
@@ -693,7 +693,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
|
||||
// Now, and not before: a module whose resources are computed replaces them wholesale, and
|
||||
// merging earlier would throw away the files it still needs.
|
||||
resources = append(append([]map[string]any{}, first...), resources...)
|
||||
//
|
||||
// **Except the module's own accounts, which go before even those** (novox/hq issue 213). What
|
||||
// the mesh computes may belong to one: a module whose code runs as an account it declares has
|
||||
// its secrets written owned by that account, and a file given to a user the machine does not
|
||||
// have yet fails — so on the first apply the secrets were refused, the process started without
|
||||
// them, and the second apply healed it, which is the fault the paragraph above describes.
|
||||
// An account depends on nothing the mesh computes.
|
||||
accounts, rest := accountsFirst(resources)
|
||||
resources = append(append(accounts, first...), rest...)
|
||||
|
||||
// No container is given the mesh's names (novox/hq ADR 0148). It used to be: every
|
||||
// container got the whole roster as `--add-host` entries at creation, and a name that
|
||||
@@ -872,6 +880,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
|
||||
copied["reload-on"] = renamed
|
||||
}
|
||||
// And what a process replaces (novox/hq issue 213): a resource of this module's that it
|
||||
// no longer declares, named as the host recorded it, or the host hands nothing over and
|
||||
// removes it first.
|
||||
if renamed := reflectsRenamed(m.Module, resource["replaces"]); renamed != nil {
|
||||
copied["replaces"] = renamed
|
||||
}
|
||||
// **What reads one of this module's own secrets is restarted when it changes** (novox/hq
|
||||
// issue 203, issue 206). A credential is re-issued by the mesh, and a container that
|
||||
// mounted the old file keeps the old one open: the build machine ran for an hour on a
|
||||
@@ -2010,12 +2024,18 @@ func preparationTarget(m Manifest) string {
|
||||
return ""
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" || !ownArtifact(r, m.Module) {
|
||||
// A container, or a process the host runs from a bundle the module built (novox/hq issue
|
||||
// 213): the same program in the same context, hosted as a unit rather than a container.
|
||||
kind := fmt.Sprint(r["type"])
|
||||
if (kind != "container" && kind != "process") || !ownArtifact(r, m.Module) {
|
||||
continue
|
||||
}
|
||||
if once, _ := r["run-once"].(bool); once {
|
||||
continue
|
||||
}
|
||||
if r["schedule"] != nil {
|
||||
continue
|
||||
}
|
||||
return fmt.Sprint(r["id"])
|
||||
}
|
||||
return ""
|
||||
@@ -2029,7 +2049,10 @@ func ownArtifact(resource map[string]any, module string) bool {
|
||||
return true
|
||||
}
|
||||
image, _ := resource["image"].(string)
|
||||
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/")
|
||||
// A process or an archive carries what was built as its source (novox/hq issue 213).
|
||||
source, _ := resource["source"].(string)
|
||||
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/") ||
|
||||
strings.HasPrefix(source, ArtifactStoreScheme+module+"/")
|
||||
}
|
||||
|
||||
// prepared is the module's own resource as the step that prepares its state: the same image, the same
|
||||
@@ -2051,7 +2074,19 @@ func prepared(from map[string]any) map[string]any {
|
||||
step["id"] = fmt.Sprint(from["id"]) + "-prepare"
|
||||
step["name"] = fmt.Sprint(from["name"]) + "-prepare"
|
||||
step["run-once"] = true
|
||||
step["args"] = []any{PreparationArgument}
|
||||
if fmt.Sprint(from["type"]) == "process" {
|
||||
// A process says its whole command: the program, then its arguments. The step is the same
|
||||
// program asked to prepare (novox/hq issue 213). It replaces nothing — what the process
|
||||
// replaces is handed over to the process, never to the step that runs before it — and a
|
||||
// step is not restarted, it runs again when what it reads changed, which `restart-on` says.
|
||||
run := stringsIn(from["run"])
|
||||
if len(run) > 0 {
|
||||
step["run"] = []any{run[0], PreparationArgument}
|
||||
}
|
||||
delete(step, "replaces")
|
||||
} else {
|
||||
step["args"] = []any{PreparationArgument}
|
||||
}
|
||||
delete(step, "ports")
|
||||
delete(step, "ip")
|
||||
delete(step, "schedule")
|
||||
@@ -2196,3 +2231,16 @@ func withRestartOn(have any, add []string) []any {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accountsFirst splits a module's resources into its accounts and everything else, each in the order
|
||||
// written.
|
||||
func accountsFirst(resources []map[string]any) (accounts, rest []map[string]any) {
|
||||
for _, r := range resources {
|
||||
if fmt.Sprint(r["type"]) == "user" {
|
||||
accounts = append(accounts, r)
|
||||
continue
|
||||
}
|
||||
rest = append(rest, r)
|
||||
}
|
||||
return accounts, rest
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -170,7 +171,7 @@ func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) {
|
||||
// **And the forge's own address follows it**, composed from the manifest in the catalogue beside
|
||||
// this checkout (novox/hq 04-ISSUES/088).
|
||||
//
|
||||
// The forge is reached a third way that neither test above covers: by its own sidecar, over the
|
||||
// The forge is reached a third way that neither test above covers: by its own code, over the
|
||||
// machine's loopback, told where to go in its environment. The `2999:3000` mapping that lets the
|
||||
// forge go on binding 3000 does nothing for a caller dialling the machine — so a literal there is
|
||||
// wrong on every node whose assignment differs, and wrong for a second reason on a node given the
|
||||
@@ -178,13 +179,13 @@ func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) {
|
||||
// in an `env` at all is a declaration, not a manifest.
|
||||
func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
||||
forge, err := catalogueManifest(t, "gitea").Resolve([]Built{{
|
||||
Name: "runtime", Kind: ArtifactImage,
|
||||
Reference: "registry.example/gitea-runtime@sha256:" + strings.Repeat("a", 64),
|
||||
Name: "code", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "gitea/code/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
|
||||
}
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}, Needs: []Needed{
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge, theRuntime(t)}, Needs: []Needed{
|
||||
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
|
||||
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
|
||||
{Name: "route", For: "gitea", From: "anchor"},
|
||||
@@ -194,8 +195,8 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
||||
|
||||
// The number this node was given for the forge — the one the machine it is about to run on
|
||||
// already publishes.
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"gitea": {"broker": "sealed-broker"}},
|
||||
out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}},
|
||||
Given: map[string]map[int]int{"gitea": {3000: 2999}},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -210,14 +211,19 @@ func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
|
||||
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "2999:3000") {
|
||||
t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"])
|
||||
}
|
||||
runtime := fileNamed(out, "gitea.runtime")
|
||||
// The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there.
|
||||
runtime := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if runtime == nil {
|
||||
t.Fatalf("the forge's sidecar is not in the declaration: %v", out)
|
||||
t.Fatalf("the node's runtime is not in the declaration: %v", ids(out))
|
||||
}
|
||||
env, _ := runtime["env"].(map[string]any)
|
||||
if env["MESH_GITEA_URL"] != "http://127.0.0.1:2999" {
|
||||
t.Fatalf("the forge's sidecar dials %v while the machine publishes the forge on 2999 — "+
|
||||
"whatever reads it dials a dead port", env["MESH_GITEA_URL"])
|
||||
env, _ := runtime["env"].(map[string]string)
|
||||
var given map[string]map[string]string
|
||||
if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil {
|
||||
t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv])
|
||||
}
|
||||
if given["gitea"]["MESH_GITEA_URL"] != "http://127.0.0.1:2999" {
|
||||
t.Fatalf("the forge's code dials %v while the machine publishes the forge on 2999 — "+
|
||||
"whatever reads it dials a dead port", given["gitea"]["MESH_GITEA_URL"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,13 +235,13 @@ func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
|
||||
t.Helper()
|
||||
forge := catalogueManifest(t, "gitea")
|
||||
resolved, err := forge.Resolve([]Built{{
|
||||
Name: "runtime", Kind: ArtifactImage,
|
||||
Reference: "registry.example/gitea-runtime@sha256:" + strings.Repeat("a", 64),
|
||||
Name: "code", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "gitea/code/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
|
||||
}
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{resolved}, Needs: []Needed{
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{resolved, theRuntime(t)}, Needs: []Needed{
|
||||
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
|
||||
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
|
||||
{Name: "route", For: "gitea", From: "anchor"},
|
||||
@@ -246,8 +252,8 @@ func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
|
||||
for k, v := range given {
|
||||
givenPorts[k] = v
|
||||
}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"gitea": {"broker": "sealed-broker"}},
|
||||
out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-broker"}},
|
||||
Ports: map[string]map[int]int{"gitea": givenPorts},
|
||||
Given: map[string]map[int]int{"gitea": given},
|
||||
})
|
||||
|
||||
@@ -757,6 +757,11 @@ type Artifact struct {
|
||||
// list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles.
|
||||
Loads []string `json:"loads,omitempty"`
|
||||
|
||||
// External are packages a TypeScript bundle keeps as imports rather than inlining — a native
|
||||
// addon, a package that reads its own files — and so carries the toolchain's node_modules for
|
||||
// (novox/hq ADR 0193). Absent for nearly every bundle, which is then one file per entrypoint.
|
||||
External []string `json:"external,omitempty"`
|
||||
|
||||
// Env is what a tools bundle is given on a machine (novox/hq ADR 0192): words and their values,
|
||||
// paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment
|
||||
// is, never a secret's content. The node's runtime hands it to this bundle and to no other.
|
||||
@@ -1510,6 +1515,53 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
"program that reads what the mesh delivered and reconciles",
|
||||
m.Module, r["id"]))
|
||||
}
|
||||
// **What a process replaces is something the module no longer declares** (novox/hq issue 213).
|
||||
// The host keeps it running until the process is, then removes it: so it is named by the id the
|
||||
// module used to give it, it is never a resource the module still declares — that would be
|
||||
// applied and removed by one declaration — and only a process that stays up has anything to
|
||||
// hand over to. Said here, near the author, as the host would refuse it far away.
|
||||
ids := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
ids[fmt.Sprint(r["id"])] = true
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
raw, present := r["replaces"]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "process" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v says what it replaces, and only a process does", m.Module, r["id"]))
|
||||
continue
|
||||
}
|
||||
if once, _ := r["run-once"].(bool); once || r["schedule"] != nil {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v replaces something and runs once or on a schedule — only a process that stays "+
|
||||
"up is there a moment later to hand over to", m.Module, r["id"]))
|
||||
}
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v replaces %v; replaces is a list of the ids this module no longer declares",
|
||||
m.Module, r["id"], raw))
|
||||
continue
|
||||
}
|
||||
for _, item := range list {
|
||||
id, ok := item.(string)
|
||||
switch {
|
||||
case !ok || strings.TrimSpace(id) == "":
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v replaces %v, which is not an id", m.Module, r["id"], item))
|
||||
case strings.Contains(id, "."):
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v replaces %q; a process replaces only a resource of its own module, named "+
|
||||
"by its own id", m.Module, r["id"], id))
|
||||
case ids[id]:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %v replaces %q, which this module still declares", m.Module, r["id"], id))
|
||||
}
|
||||
}
|
||||
}
|
||||
// **A module that prepares its state must have code the mesh can run** (novox/hq ADR 0135). The
|
||||
// preparation is the module's own program in its preparation mode, so it is derived from the
|
||||
// resource that runs that program — and a module declaring none has asked for something the mesh
|
||||
@@ -1517,7 +1569,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
// quietly prepares nothing.
|
||||
if m.Prepares && preparationTarget(m) == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s says it prepares its state, and declares no container running an artifact it built — "+
|
||||
"%s says it prepares its state, and declares no container or process running an artifact it built — "+
|
||||
"the preparation is this module's own program, so there has to be one for the mesh to "+
|
||||
"run it in", m.Module))
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import (
|
||||
//
|
||||
// So a module asks. `${port:8080}` is "the machine-side port you gave me for the 8080 I said I
|
||||
// listen on", and the module writes that where it would otherwise have written a literal — in a
|
||||
// file's content, or in a value of a container's `env`.
|
||||
// file's content, or in a value of a container's or a process's `env`.
|
||||
//
|
||||
// **The environment is filled by the control plane, exactly as a bound value is.** A port is not
|
||||
// secret — the mesh holds it in the clear — so there is nothing for the host to be the only
|
||||
@@ -64,7 +64,12 @@ func portsUsed(content string) []int {
|
||||
}
|
||||
|
||||
// portInto replaces a resource's ${port:…} placeholders with what this machine assigned — in a
|
||||
// file's content, and in a value of a container's environment.
|
||||
// file's content, and in a value of a container's or a process's environment.
|
||||
//
|
||||
// **A process's environment is a container's** (novox/hq to-be 38 WP4c). A module's code moving out
|
||||
// of its container becomes a process on the machine and still has to be told what the container
|
||||
// was told; filled for one kind and not the other, the literal reached the process and was read as
|
||||
// a port, and the modules that moved first wrote their run-once steps a 0600 env file instead.
|
||||
//
|
||||
// A port the module did not say it listens on is refused, for the same reason a binding's unknown
|
||||
// key is: the module is asking about something it never declared, and the answer would be a guess.
|
||||
@@ -84,7 +89,7 @@ func portInto(resource map[string]any, module string, listens []Listening, with
|
||||
}
|
||||
resource["content"] = filled
|
||||
|
||||
case "container":
|
||||
case "container", "process":
|
||||
env, ok := resource["env"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -106,8 +111,8 @@ func portInto(resource map[string]any, module string, listens []Listening, with
|
||||
continue
|
||||
}
|
||||
value, err := portsFilledInto(written,
|
||||
fmt.Sprintf("%s's container %s sets %s to something that",
|
||||
module, resource["name"], key), module, listens, with)
|
||||
fmt.Sprintf("%s's %s %s sets %s to something that",
|
||||
module, resource["type"], resource["name"], key), module, listens, with)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A process's environment is composed as a container's is** (novox/hq to-be 38 WP4c).
|
||||
//
|
||||
// A module's code moving out of its container becomes a process on the machine, and what its
|
||||
// container's environment asked for — the port this machine gave the module, the place it put the
|
||||
// module's directory — it still has to be told. Filled for a container and not for a process, the
|
||||
// literal `${port:8080}` reached the process as its environment and was read as a port; the modules
|
||||
// that moved first wrote their run-once steps an env file instead.
|
||||
func processModule(env map[string]any) Manifest {
|
||||
return Manifest{
|
||||
Module: "showcase",
|
||||
Listens: []Listening{{Port: 8080, From: FromMesh}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "data", "type": "directory", "mode": "0700"},
|
||||
{"id": "setup", "type": "process", "name": "showcase-setup", "run-once": true,
|
||||
"run": []any{"/usr/bin/showcase", "setup"}, "env": env},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProcessIsToldItsPortAndItsPlaceInItsEnvironment(t *testing.T) {
|
||||
env := map[string]any{
|
||||
"SHOWCASE_URL": "http://127.0.0.1:${port:8080}",
|
||||
"SHOWCASE_DATA": "${dir:data}/objects",
|
||||
"SHOWCASE_DB": "127.0.0.1:${seat:mesh-store:5432}",
|
||||
"GREETING": "hello",
|
||||
}
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}.Declaration(Rendering{
|
||||
Ports: map[string]map[int]int{"showcase": {8080: 21000}},
|
||||
Seats: map[string]map[int]int{"mesh-store": {5432: 6852}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a process asking for its port and its place does not compose: %v", err)
|
||||
}
|
||||
setup := fileNamed(out, "showcase.setup")
|
||||
if setup == nil {
|
||||
t.Fatalf("the process is not in the declaration: %v", out)
|
||||
}
|
||||
got, _ := setup["env"].(map[string]any)
|
||||
for key, want := range map[string]string{
|
||||
"SHOWCASE_URL": "http://127.0.0.1:21000",
|
||||
"SHOWCASE_DATA": "/var/lib/showcase/data/objects",
|
||||
"SHOWCASE_DB": "127.0.0.1:6852",
|
||||
"GREETING": "hello",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Errorf("the process is told %s=%v, want %q", key, got[key], want)
|
||||
}
|
||||
}
|
||||
if env["SHOWCASE_URL"] != "http://127.0.0.1:${port:8080}" {
|
||||
t.Fatalf("composing for one machine edited the module's own manifest: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown reference in a process's environment is refused as a container's is, naming the
|
||||
// process and the variable — left alone, it would reach the machine as a literal.
|
||||
func TestAProcessAskingAboutAnUndeclaredPortIsRefused(t *testing.T) {
|
||||
env := map[string]any{"SHOWCASE_URL": "http://127.0.0.1:${port:9999}"}
|
||||
_, err := Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}.Declaration(Rendering{})
|
||||
if err == nil {
|
||||
t.Fatal("a process was told a port its module never said it listens on")
|
||||
}
|
||||
for _, said := range []string{"showcase-setup", "SHOWCASE_URL", "${port:9999}", "8080"} {
|
||||
if !strings.Contains(err.Error(), said) {
|
||||
t.Errorf("the refusal does not say %q: %v", said, err)
|
||||
}
|
||||
}
|
||||
|
||||
env = map[string]any{"SHOWCASE_DATA": "${dir:date}/objects"}
|
||||
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}).Declaration(Rendering{}); err == nil ||
|
||||
!strings.Contains(err.Error(), "${dir:date}") {
|
||||
t.Fatalf("a process naming no directory of its module was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,8 @@ import (
|
||||
var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
|
||||
|
||||
// seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's
|
||||
// holder — in a file's content, and in a value of a container's environment. The same two places
|
||||
// portInto fills, for the same reason: they are where a process reads a number from.
|
||||
// holder — in a file's content, and in a value of a container's or a process's environment. The
|
||||
// same places portInto fills, for the same reason: they are where a program reads a number from.
|
||||
func seatInto(resource map[string]any, module string, with Rendering) error {
|
||||
switch fmt.Sprint(resource["type"]) {
|
||||
case "file":
|
||||
@@ -58,7 +58,7 @@ func seatInto(resource map[string]any, module string, with Rendering) error {
|
||||
}
|
||||
resource["content"] = filled
|
||||
|
||||
case "container":
|
||||
case "container", "process":
|
||||
env, ok := resource["env"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -78,8 +78,8 @@ func seatInto(resource map[string]any, module string, with Rendering) error {
|
||||
continue
|
||||
}
|
||||
value, err := seatsFilledInto(written,
|
||||
fmt.Sprintf("%s's container %s sets %s to something that",
|
||||
module, resource["name"], key), with)
|
||||
fmt.Sprintf("%s's %s %s sets %s to something that",
|
||||
module, resource["type"], resource["name"], key), with)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -42,9 +42,29 @@ type Build struct {
|
||||
// Failed is the builder's own words, empty when it worked.
|
||||
Failed string
|
||||
Made []Artifact
|
||||
At time.Time
|
||||
// Asked is when the build was requested, zero when that is not known (an id of another shape,
|
||||
// or a build recorded before the mesh kept it). **What orders one build of a module against
|
||||
// another** (novox/hq 04-ISSUES/219): builds in flight together finish in any order, and the
|
||||
// one asked last stood on the newest bases.
|
||||
Asked time.Time
|
||||
// At is when the outcome was recorded — when it finished, not when it was asked.
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// AskedOrAt is when the build was asked, or when it was recorded when that is not known — the
|
||||
// order the mesh had before it kept the request time.
|
||||
func (b Build) AskedOrAt() time.Time {
|
||||
if !b.Asked.IsZero() {
|
||||
return b.Asked
|
||||
}
|
||||
return b.At
|
||||
}
|
||||
|
||||
// newestRequestFirst is the ordering every "what a module currently is" question uses: the newest
|
||||
// request wins, whenever it finished (novox/hq 04-ISSUES/219). A build whose request time is not
|
||||
// known is placed at the moment it was recorded, which is the rule that held before.
|
||||
const newestRequestFirst = `coalesce(asked, at) desc, at desc`
|
||||
|
||||
// ReadRepository is a repository a build read source from besides the module's own.
|
||||
type ReadRepository struct {
|
||||
Repository string `json:"repository"`
|
||||
@@ -83,13 +103,17 @@ func (i *Inventory) RecordBuild(ctx context.Context, b Build) error {
|
||||
if b.Module != "" {
|
||||
module = &b.Module
|
||||
}
|
||||
var asked *time.Time
|
||||
if !b.Asked.IsZero() {
|
||||
asked = &b.Asked
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made,
|
||||
source_path, manifest, built_against, built_contexts)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
source_path, manifest, built_against, built_contexts, asked)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
on conflict (id) do nothing`,
|
||||
b.ID, b.Repository, b.Ref, module, b.Commit, b.On, b.Failed, made,
|
||||
b.Path, manifestOrNil(b.Manifest), against, read)
|
||||
b.Path, manifestOrNil(b.Manifest), against, read, asked)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -102,11 +126,11 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
query := `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, at
|
||||
query := `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, asked, at
|
||||
from build order by at desc limit $1`
|
||||
args := []any{limit}
|
||||
if module != "" {
|
||||
query = `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, at
|
||||
query = `select id, repository, ref, coalesce(module,''), commit_hash, built_on, failed, made, asked, at
|
||||
from build where module = $2 order by at desc limit $1`
|
||||
args = append(args, module)
|
||||
}
|
||||
@@ -121,10 +145,14 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui
|
||||
for rows.Next() {
|
||||
var b Build
|
||||
var made []byte
|
||||
var asked *time.Time
|
||||
if err := rows.Scan(&b.ID, &b.Repository, &b.Ref, &b.Module, &b.Commit,
|
||||
&b.On, &b.Failed, &made, &b.At); err != nil {
|
||||
&b.On, &b.Failed, &made, &asked, &b.At); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if asked != nil {
|
||||
b.Asked = *asked
|
||||
}
|
||||
if err := json.Unmarshal(made, &b.Made); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -135,8 +163,9 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui
|
||||
|
||||
// Held is every artifact this mesh has built, keyed "<module>/<artifact>".
|
||||
//
|
||||
// **The newest successful build of each module wins**, which is the same rule the rest of the mesh
|
||||
// uses for what a module currently is. A module rebuilt to something broken and then rebuilt again
|
||||
// **The successful build of each module asked last wins**, which is the same rule the rest of the
|
||||
// mesh uses for what a module currently is — asked last, not finished last (novox/hq
|
||||
// 04-ISSUES/219): an older request that finishes later stood on older bases. A module rebuilt to something broken and then rebuilt again
|
||||
// is at the second one; a module whose last build failed is at the last one that worked, because a
|
||||
// failure published nothing and the thing it published before is still what exists.
|
||||
//
|
||||
@@ -147,7 +176,7 @@ func (i *Inventory) Held(ctx context.Context) (map[string]string, error) {
|
||||
`select distinct on (module) module, made
|
||||
from build
|
||||
where module is not null and module <> '' and failed = ''
|
||||
order by module, at desc`)
|
||||
order by module, `+newestRequestFirst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -185,7 +214,7 @@ func (i *Inventory) BuiltAgainst(ctx context.Context) (map[string][]string, erro
|
||||
`select distinct on (module) module, built_against
|
||||
from build
|
||||
where module is not null and module <> '' and failed = ''
|
||||
order by module, at desc`)
|
||||
order by module, `+newestRequestFirst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -223,7 +252,7 @@ func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepo
|
||||
`select distinct on (module) module, built_contexts
|
||||
from build
|
||||
where module is not null and module <> '' and failed = ''
|
||||
order by module, at desc`)
|
||||
order by module, `+newestRequestFirst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -274,7 +303,7 @@ func manifestOrNil(raw []byte) any {
|
||||
// follows when it decides whether to announce at all.
|
||||
//
|
||||
// One row per module and commit: a module built twice at the same commit is one fact, and the
|
||||
// latest row is the one whose artifacts are current.
|
||||
// row asked last is the one whose artifacts are current (novox/hq 04-ISSUES/219).
|
||||
func (i *Inventory) Announceable(ctx context.Context) ([]Build, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select distinct on (module, commit_hash)
|
||||
@@ -282,7 +311,7 @@ func (i *Inventory) Announceable(ctx context.Context) ([]Build, error) {
|
||||
source_path, manifest, built_against, at
|
||||
from build
|
||||
where failed = '' and module is not null and module <> '' and commit_hash <> ''
|
||||
order by module, commit_hash, at desc`)
|
||||
order by module, commit_hash, `+newestRequestFirst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -49,6 +49,12 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Who holds each seat held once for the mesh, where the mesh recorded it (novox/hq issue 218).
|
||||
holdings, err := i.Holdings(ctx)
|
||||
if err != nil {
|
||||
return broker.Records{}, fmt.Errorf("cannot read who holds the mesh's seats: %w", err)
|
||||
}
|
||||
|
||||
out := broker.Records{Assigned: map[string][]broker.Declared{}, People: map[string][]string{},
|
||||
Interchangeable: map[string]bool{}}
|
||||
for _, n := range nodes {
|
||||
@@ -72,7 +78,9 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
||||
"%s is assigned to %s and is not in the catalogue, so what it may say cannot "+
|
||||
"be derived", module, n.Name)
|
||||
}
|
||||
out.Assigned[n.Name] = append(out.Assigned[n.Name], declaredFor(m, seats))
|
||||
d := declaredFor(m, seats)
|
||||
d.Holds = heldHere(d.Holds, holdings, n.Name, module)
|
||||
out.Assigned[n.Name] = append(out.Assigned[n.Name], d)
|
||||
if m.Instances == catalogue.InstancesInterchangeable {
|
||||
out.Interchangeable[m.Module] = true
|
||||
}
|
||||
@@ -183,3 +191,33 @@ func (i *Inventory) NodesWithALiveToken(ctx context.Context) ([]string, error) {
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// heldHere keeps of what a module claims only the seats it holds on this machine (novox/hq issue 218).
|
||||
// A seat held once per machine is held by every assignment that claims it. A seat held once for the
|
||||
// mesh is held by one assignment: where the mesh recorded who holds it, a claim on any other machine
|
||||
// grants nothing and issues nothing — or the module would serve the role's verbs from a machine that
|
||||
// is not the role's, and a question to the mesh's store would be answered from the wrong database. A
|
||||
// mesh seat with no holder on record is left as it was derived.
|
||||
func heldHere(claimed []broker.Seat, holdings []catalogue.Held, node, module string) []broker.Seat {
|
||||
recorded := map[string][]catalogue.Held{}
|
||||
for _, h := range holdings {
|
||||
if h.Scope == catalogue.ScopeMesh {
|
||||
recorded[h.Claim] = append(recorded[h.Claim], h)
|
||||
}
|
||||
}
|
||||
var out []broker.Seat
|
||||
for _, s := range claimed {
|
||||
holders, onRecord := recorded[s.Name]
|
||||
if s.Scope != catalogue.ScopeMesh || !onRecord {
|
||||
out = append(out, s)
|
||||
continue
|
||||
}
|
||||
for _, h := range holders {
|
||||
if h.Node == node && h.Module == module {
|
||||
out = append(out, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ import (
|
||||
// ErrNoSuchModule is what the mesh says about a module it has never been told about.
|
||||
var ErrNoSuchModule = errors.New("no module of that name")
|
||||
|
||||
// ErrSuperseded is a registration from a build asked before the one the module is already at
|
||||
// (novox/hq 04-ISSUES/219). The build is recorded; what the module is does not change.
|
||||
var ErrSuperseded = errors.New("a build asked later is already what the module is")
|
||||
|
||||
// ErrStillAssigned is why a module cannot be forgotten.
|
||||
//
|
||||
// Its own error because it is not a fault: it means a machine is running that module now, and
|
||||
@@ -47,6 +51,10 @@ type Source struct {
|
||||
// itself no longer carries its build (novox/hq to-be 38 WP2.4). Empty for a manifest handed over
|
||||
// by hand, which carries its `build.on` itself.
|
||||
Against []string
|
||||
// Asked is when the build this manifest came from was requested (novox/hq 04-ISSUES/219). Zero
|
||||
// is a manifest handed over by hand, or a build whose request time is not known: either is
|
||||
// taken as asked at the moment it is registered.
|
||||
Asked time.Time
|
||||
}
|
||||
|
||||
// Current reports whether what the mesh holds is what the source last had.
|
||||
@@ -97,13 +105,23 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
||||
return err
|
||||
}
|
||||
|
||||
asked := from.Asked
|
||||
if asked.IsZero() {
|
||||
asked = time.Now()
|
||||
}
|
||||
|
||||
// A module registered without provenance keeps whatever it had. Handing over a manifest by
|
||||
// hand is a legitimate way to fix something in a hurry, and it should not silently erase the
|
||||
// record of where the module normally comes from — which is the only thing that would say,
|
||||
// afterwards, that the machine is running something nobody can rebuild.
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into module (name, manifest, version, source, source_path, source_seat, ref, built_from, source_head)
|
||||
values ($1, $2, nullif($3,''), nullif($4,''), $7, $8, nullif($5,''), nullif($6,''), nullif($6,''))
|
||||
//
|
||||
// **An older request never replaces a newer one** (novox/hq 04-ISSUES/219). Builds of one
|
||||
// module in flight together finish in any order, and each stood on the bases the mesh held when
|
||||
// it was asked; the one asked later is what the module is, whichever is heard last. An outcome
|
||||
// of an earlier request is kept in the build records and changes nothing here.
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`insert into module (name, manifest, version, source, source_path, source_seat, ref, built_from, source_head, built_asked)
|
||||
values ($1, $2, nullif($3,''), nullif($4,''), $7, $8, nullif($5,''), nullif($6,''), nullif($6,''), $9)
|
||||
on conflict (name) do update set
|
||||
manifest = excluded.manifest,
|
||||
version = excluded.version,
|
||||
@@ -115,9 +133,23 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
||||
else excluded.source_seat end,
|
||||
ref = coalesce(excluded.ref, module.ref),
|
||||
built_from = coalesce(excluded.built_from, module.built_from),
|
||||
source_head = coalesce(excluded.built_from, module.source_head)`,
|
||||
m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom, from.Path, from.Seat)
|
||||
return err
|
||||
source_head = coalesce(excluded.built_from, module.source_head),
|
||||
built_asked = excluded.built_asked
|
||||
where module.built_asked is null or module.built_asked <= excluded.built_asked`,
|
||||
m.Module, raw, m.Version, from.Repository, from.Ref, from.BuiltFrom, from.Path, from.Seat, asked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
var current time.Time
|
||||
if err := i.store.Pool().QueryRow(ctx,
|
||||
`select built_asked from module where name = $1`, m.Module).Scan(¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %s is at a build asked %s, and this one was asked %s",
|
||||
ErrSuperseded, m.Module, current.UTC().Format(time.RFC3339), asked.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registeredInThatShape is whether the catalogue already holds this module as a tools container on
|
||||
|
||||
@@ -100,3 +100,23 @@ func TestABundleStandsOnTheToolchainItIsCompiledIn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/212: a toolchain standing on the SDK's package is planned after the SDK, so a
|
||||
// release of the SDK rebuilds the toolchain, and every bundle compiled in it after that.
|
||||
func TestAToolchainStandingOnTheSDKFollowsIt(t *testing.T) {
|
||||
entries := []Entry{
|
||||
{Manifest: catalogue.Manifest{Module: "mesh-sdk"}},
|
||||
{Manifest: catalogue.Manifest{Module: "mesh-tools", Build: &catalogue.Build{
|
||||
On: []catalogue.BuildsOn{{Arg: "MESH_SDK", Module: "mesh-sdk", Artifact: "lib"}}}}},
|
||||
}
|
||||
edges := dependenciesOf(entries, nil, nil)
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
if e.From == "mesh-tools" && e.To == "mesh-sdk" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no edge from the toolchain to the SDK: %v", edges)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// novox/hq issue 218: a seat held once for the mesh is granted and issued only to the holder on record;
|
||||
// a node seat to every machine's claimant; a mesh seat with no holder on record as derived.
|
||||
func TestOnlyTheRecordedHolderHoldsAMeshSeat(t *testing.T) {
|
||||
claimed := []broker.Seat{
|
||||
{Name: "mesh-store", Scope: catalogue.ScopeMesh},
|
||||
{Name: "node-packet-filter", Scope: catalogue.ScopeNode},
|
||||
{Name: "unrecorded", Scope: catalogue.ScopeMesh},
|
||||
}
|
||||
holdings := []catalogue.Held{{Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "control", Module: "postgres"}}
|
||||
names := func(ss []broker.Seat) (out []string) {
|
||||
for _, s := range ss {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := names(heldHere(claimed, holdings, "control", "postgres")); len(got) != 3 {
|
||||
t.Errorf("the holder lost a seat: %v", got)
|
||||
}
|
||||
got := names(heldHere(claimed, holdings, "other", "postgres"))
|
||||
if len(got) != 2 || got[0] != "node-packet-filter" || got[1] != "unrecorded" {
|
||||
t.Errorf("a claimant on another machine holds %v; want the node seat and the unrecorded one, not the store", got)
|
||||
}
|
||||
}
|
||||
@@ -87,3 +87,62 @@ func (i *Inventory) tryHold(ctx context.Context, sorted []string) (func(), strin
|
||||
}
|
||||
return release, "", nil
|
||||
}
|
||||
|
||||
// ErrPlansBusy is the plans held by another act — on a machine replacing its controller, the other
|
||||
// controller — for longer than a caller waits, or at all for one that does not wait.
|
||||
var ErrPlansBusy = errors.New("another controller is working the plans")
|
||||
|
||||
// HoldPlans makes working the plans one act at a time, across every controller on the store
|
||||
// (novox/hq issue 213). A plan is read, changed and written whole; two controllers doing that at
|
||||
// once — the old and the new for the moment a machine hands its controller over, or a controller
|
||||
// and a person's `plans stop` — each act on what the other has not saved yet: a tier asked twice,
|
||||
// an outcome written over. A session-level advisory lock on one connection, released by the
|
||||
// returned function and by the session ending, so a controller that dies holding it holds nothing.
|
||||
//
|
||||
// wait false gives ErrPlansBusy at once when another holds them — the timer's way: the holder is
|
||||
// moving the plans already. wait true looks again every HoldPoll for up to HoldWaitFor — an
|
||||
// outcome's or a merge's way, which must be written.
|
||||
func (i *Inventory) HoldPlans(ctx context.Context, wait bool) (func(), error) {
|
||||
deadline := time.Now().Add(HoldWaitFor)
|
||||
for {
|
||||
release, took, err := i.tryLock(ctx, "mesh-plans")
|
||||
if err != nil || took {
|
||||
return release, err
|
||||
}
|
||||
if !wait || time.Now().After(deadline) {
|
||||
return nil, ErrPlansBusy
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(HoldPoll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryLock takes one named advisory lock on a connection of its own, or gives the connection back.
|
||||
func (i *Inventory) tryLock(ctx context.Context, key string) (func(), bool, error) {
|
||||
conn, err := i.store.Pool().Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var once sync.Once
|
||||
release := func() {
|
||||
once.Do(func() {
|
||||
if _, err := conn.Exec(context.WithoutCancel(ctx), `select pg_advisory_unlock_all()`); err != nil {
|
||||
_ = conn.Conn().Close(context.WithoutCancel(ctx))
|
||||
}
|
||||
conn.Release()
|
||||
})
|
||||
}
|
||||
var took bool
|
||||
if err := conn.QueryRow(ctx, `select pg_try_advisory_lock(hashtext($1)::bigint)`, key).Scan(&took); err != nil {
|
||||
release()
|
||||
return nil, false, err
|
||||
}
|
||||
if !took {
|
||||
release()
|
||||
return nil, false, nil
|
||||
}
|
||||
return release, true, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 213: while a machine hands its controller over from the container to the process,
|
||||
// two controllers run on one store for a moment. Working the plans is one act at a time across them.
|
||||
func TestThePlansAreWorkedByOneControllerAtATime(t *testing.T) {
|
||||
first := ForTest(t)
|
||||
// A second controller: its own connections to the same store.
|
||||
second, err := Open(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(second.Close)
|
||||
|
||||
release, err := first.HoldPlans(t.Context(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("the plans could not be held when nobody held them: %v", err)
|
||||
}
|
||||
t.Cleanup(release) // a pool closing waits for a connection still held; release is idempotent
|
||||
if _, err := second.HoldPlans(t.Context(), false); !errors.Is(err, ErrPlansBusy) {
|
||||
t.Fatalf("a second controller held the plans while the first did: %v", err)
|
||||
}
|
||||
// A waiter gets them once they are let go.
|
||||
was := HoldPoll
|
||||
HoldPoll = 10 * time.Millisecond
|
||||
defer func() { HoldPoll = was }()
|
||||
go func() { time.Sleep(50 * time.Millisecond); release() }()
|
||||
again, err := second.HoldPlans(t.Context(), true)
|
||||
if err != nil {
|
||||
t.Fatalf("a waiting controller never got the plans once they were let go: %v", err)
|
||||
}
|
||||
again()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
-- A build is ordered by when it was asked, not when it finished (novox/hq 04-ISSUES/219).
|
||||
--
|
||||
-- Two builds of one module can be in flight together — two merge plans a few minutes apart, each
|
||||
-- asking for everything standing on what it changed — and they finish in any order. Each build
|
||||
-- stands on the bases the mesh held when it was *asked*, so the one asked later is the newer one.
|
||||
-- The mesh ordered builds by `at`, which is when the outcome was recorded, and registered whatever
|
||||
-- it heard last: an older request that took longer replaced a newer one as what the module is, and
|
||||
-- the next push sent machines an image built on a base the mesh had already replaced.
|
||||
--
|
||||
-- `build.asked` is when the build was requested, read from the correlation id the controller wrote
|
||||
-- (`build-<unix nanoseconds>`). Nullable: an id of any other shape says no request time, and such a
|
||||
-- build is placed where it was recorded, which is the order the mesh had before this.
|
||||
alter table build add column asked timestamptz;
|
||||
|
||||
update build
|
||||
set asked = to_timestamp((substring(id from '^build-([0-9]{19})$'))::numeric / 1000000000)
|
||||
where id ~ '^build-[0-9]{19}$';
|
||||
|
||||
-- `module.built_asked` is when the build the module's registered manifest came from was asked, so
|
||||
-- a later-heard outcome of an earlier request is recorded and not registered. A manifest handed over
|
||||
-- by hand is a request made when it is handed over. Null for a module registered before this was
|
||||
-- kept: its next registration, whichever it is, sets it.
|
||||
alter table module add column built_asked timestamptz;
|
||||
@@ -0,0 +1,124 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// novox/hq 04-ISSUES/219: two builds of one module in flight together, the one asked first heard
|
||||
// last. The newer request stood on the newer base; the older one's late outcome is recorded and is
|
||||
// not what the module is.
|
||||
|
||||
func postgresBuild(id string, asked time.Time, image string) Build {
|
||||
b := aBuild(id, "postgres", "")
|
||||
b.Asked = asked
|
||||
b.Against = []string{"mesh-tools/runtime@sha256:" + id}
|
||||
b.Made = []Artifact{{Name: "server", Kind: "image", Reference: "postgres@sha256:" + image}}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestAnOlderRequestFinishingLaterIsNotWhatTheModuleHolds(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
older := time.Date(2026, 10, 3, 21, 33, 45, 0, time.UTC)
|
||||
newer := time.Date(2026, 10, 3, 21, 51, 57, 0, time.UTC)
|
||||
|
||||
// The newer request finishes first, the older one last — recorded in that order.
|
||||
if err := inv.RecordBuild(ctx, postgresBuild("newer", newer, "4bcd5f73")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordBuild(ctx, postgresBuild("older", older, "0ab07fa9")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
held, err := inv.Held(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := held["postgres/server"]; got != "postgres@sha256:4bcd5f73" {
|
||||
t.Errorf("postgres holds %q; want the newer request's image 4bcd5f73", got)
|
||||
}
|
||||
against, err := inv.BuiltAgainst(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := against["postgres"]; len(got) != 1 || got[0] != "mesh-tools/runtime@sha256:newer" {
|
||||
t.Errorf("postgres stands on %v; want what the newer request stood on", got)
|
||||
}
|
||||
|
||||
// Both are still recorded, the late one first as what happened lately.
|
||||
builds, err := inv.Builds(ctx, "postgres", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(builds) != 2 || builds[0].ID != "older" || !builds[0].Asked.Equal(older) {
|
||||
t.Fatalf("both builds, newest heard first, with when they were asked: %+v", builds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuildWithNoKnownRequestTimeIsOrderedByWhenItWasRecorded(t *testing.T) {
|
||||
// What the mesh did before it kept the request time, so a row from before still answers.
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
for _, id := range []string{"first", "second"} {
|
||||
b := aBuild(id, "shell", "")
|
||||
b.Made = []Artifact{{Name: "config", Kind: "archive", Reference: "…/" + id}}
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
held, err := inv.Held(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := held["shell/config"]; got != "…/second" {
|
||||
t.Errorf("shell holds %q; want the one recorded last", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARegistrationFromAnOlderRequestDoesNotReplaceANewerOne(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
older := time.Date(2026, 10, 3, 21, 33, 45, 0, time.UTC)
|
||||
newer := time.Date(2026, 10, 3, 21, 51, 57, 0, time.UTC)
|
||||
from := func(asked time.Time) Source {
|
||||
return Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/postgres",
|
||||
BuiltFrom: "efff5415", Asked: asked}
|
||||
}
|
||||
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "fixed"}, from(newer)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "stale"}, from(older))
|
||||
if !errors.Is(err, ErrSuperseded) {
|
||||
t.Fatalf("an older request's registration was not refused as superseded: %v", err)
|
||||
}
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := shelf["postgres"].Version; got != "fixed" {
|
||||
t.Fatalf("postgres is %q; want the newer request's manifest", got)
|
||||
}
|
||||
|
||||
// A later request, and a manifest handed over by hand — asked when it is handed over — both
|
||||
// replace it as before.
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "later"},
|
||||
from(newer.Add(time.Minute))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "postgres", Version: "by-hand"}, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shelf, _ := inv.Catalogue(ctx); shelf["postgres"].Version != "by-hand" {
|
||||
t.Fatalf("postgres is %q; want the manifest handed over by hand", shelf["postgres"].Version)
|
||||
}
|
||||
src, err := inv.SourceOf(ctx, "postgres")
|
||||
if err != nil || src.Repository != "novox/mesh-catalog" {
|
||||
t.Fatalf("a hand registration erased the provenance: %+v %v", src, err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package link
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Asking a machine to build a module, and hearing what came out.
|
||||
@@ -17,6 +20,32 @@ import (
|
||||
// holds no opinion about what they contain, and a host that also built things would be a host
|
||||
// with a container runtime requirement and a git dependency (novox/hq ADR 0005).
|
||||
|
||||
// NewBuildID is the correlation for a build asked at that moment: `build-<unix nanoseconds>`.
|
||||
//
|
||||
// **The id carries when the build was asked, and that is read back** (novox/hq 04-ISSUES/219). Builds
|
||||
// of one module can be in flight together and finish in any order; what a module currently is must
|
||||
// be the newest *request's* outcome, not the last one heard, and the id is the one thing every
|
||||
// outcome echoes whichever builder answered it. One place writes the shape and one reads it.
|
||||
func NewBuildID(asked time.Time) string {
|
||||
return "build-" + strconv.FormatInt(asked.UnixNano(), 10)
|
||||
}
|
||||
|
||||
// BuildAskedAt is when the build with this id was asked, as NewBuildID wrote it. False for an id
|
||||
// of any other shape — one written before this was read, or by hand — whose request time the mesh
|
||||
// does not know.
|
||||
func BuildAskedAt(id string) (time.Time, bool) {
|
||||
digits, ok := strings.CutPrefix(id, "build-")
|
||||
if !ok || digits == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
nanos, err := strconv.ParseInt(digits, 10, 64)
|
||||
// A number too small to be a moment this mesh could have asked at is a name, not a time.
|
||||
if err != nil || nanos < time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano() {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(0, nanos).UTC(), true
|
||||
}
|
||||
|
||||
// BuildRequest is one module to build.
|
||||
type BuildRequest struct {
|
||||
// ID correlates the answer with the asking. Not the module name: two builds of one module can
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -78,10 +79,15 @@ func (n *natsInbound) Receive(ctx context.Context, act func(context.Context, Con
|
||||
// than creating one here: the consumer is an object with a configuration — ack policy, ack
|
||||
// wait, redelivery — and a client that creates its own would be a second opinion about it.
|
||||
control := make(chan *nats.Msg, Prefetch)
|
||||
said, err := js.ChanSubscribe("", control, nats.Bind("CONTROL", broker.ControllerName))
|
||||
said, err := standingBy(ctx, log.Default(), "CONTROL", func() (*nats.Subscription, error) {
|
||||
return js.ChanSubscribe("", control, nats.Bind("CONTROL", broker.ControllerName))
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribing to what nodes say: %w", err)
|
||||
}
|
||||
if said == nil {
|
||||
return nil // stopped while standing by
|
||||
}
|
||||
defer func() { _ = said.Unsubscribe() }()
|
||||
|
||||
// Heartbeats, on core NATS and off any stream (design 25 §3). Their own subscription because
|
||||
@@ -97,10 +103,15 @@ func (n *natsInbound) Receive(ctx context.Context, act func(context.Context, Con
|
||||
var events chan *nats.Msg
|
||||
if len(n.follows) > 0 {
|
||||
events = make(chan *nats.Msg, Prefetch)
|
||||
followed, err := js.ChanSubscribe("", events, nats.Bind("EVENTS", broker.ControllerName))
|
||||
followed, err := standingBy(ctx, log.Default(), "EVENTS", func() (*nats.Subscription, error) {
|
||||
return js.ChanSubscribe("", events, nats.Bind("EVENTS", broker.ControllerName))
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribing to what the catalogue says: %w", err)
|
||||
}
|
||||
if followed == nil {
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = followed.Unsubscribe() }()
|
||||
}
|
||||
|
||||
@@ -324,3 +335,44 @@ func (m *natsControl) forget() {
|
||||
type replyAddressed struct {
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
}
|
||||
|
||||
// StandbyPoll is how often a controller standing by looks again for its consumers. A variable so a
|
||||
// test need not wait.
|
||||
var StandbyPoll = 2 * time.Second
|
||||
|
||||
// standingBy binds one of the controller's consumers, waiting while another controller holds it.
|
||||
//
|
||||
// **Two controllers, one consumer** (novox/hq issue 213). The controller's consumers are push
|
||||
// consumers with no delivery group, so the server lets one subscription bind each — on purpose:
|
||||
// two would each act on every message (issue 146). When a machine hands its controller over from
|
||||
// the container to the process, the host starts the process first and removes the container only
|
||||
// once the process is up; the process then finds the consumers bound. Exiting on that would never
|
||||
// be up, so the container would never go. It stands by instead — the seat's verbs are already
|
||||
// served from a queue group, and the plans wait on their lock — and binds as soon as the other lets
|
||||
// go. Nil and no error is ctx ending while it waited.
|
||||
func standingBy(ctx context.Context, logger interface{ Printf(string, ...any) }, stream string,
|
||||
bind func() (*nats.Subscription, error)) (*nats.Subscription, error) {
|
||||
said := false
|
||||
for {
|
||||
sub, err := bind()
|
||||
if err == nil {
|
||||
if said {
|
||||
logger.Printf("took the controller's consumer on %s: the controller that held it let go", stream)
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already bound") {
|
||||
return nil, err
|
||||
}
|
||||
if !said {
|
||||
logger.Printf("another controller holds the controller's consumer on %s; standing by "+
|
||||
"until it lets go", stream)
|
||||
said = true
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil
|
||||
case <-time.After(StandbyPoll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
)
|
||||
|
||||
// novox/hq issue 213: while a machine hands its controller over, the new controller (the process)
|
||||
// is started while the old one (the container) still holds the controller's consumers. It must not
|
||||
// exit — the host would read that as a replacement that did not come up and never remove the
|
||||
// container — and must not act on what the old one is handed. It stands by, and takes the consumers
|
||||
// when the old one lets go.
|
||||
func TestNatsASecondControllerStandsByAndTakesOverWhenTheFirstLetsGo(t *testing.T) {
|
||||
js := aBus(t)
|
||||
was := StandbyPoll
|
||||
StandbyPoll = 50 * time.Millisecond
|
||||
defer func() { StandbyPoll = was }()
|
||||
|
||||
old := &counted{}
|
||||
_, stopOld := servingOn(t, js, old)
|
||||
eventually(t, "the first controller binding its consumer", func() bool {
|
||||
info, err := js.Context().ConsumerInfo("CONTROL", broker.ControllerName)
|
||||
return err == nil && info.PushBound
|
||||
})
|
||||
|
||||
// The new one, on a connection of its own as the process would have.
|
||||
second, err := broker.Dial(os.Getenv("MESH_TEST_NATS"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(second.Close)
|
||||
fresh := &counted{}
|
||||
s := &Server{inbound: Nats(second), bus: OverNATS{Conn: second.Conn(), JS: second.Context()},
|
||||
listener: fresh, log: quiet()}
|
||||
ctx, stopNew := context.WithCancel(context.Background())
|
||||
defer stopNew()
|
||||
ended := make(chan error, 1)
|
||||
go func() { ended <- s.Serve(ctx) }()
|
||||
|
||||
select {
|
||||
case err := <-ended:
|
||||
t.Fatalf("the second controller stopped instead of standing by: %v", err)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
|
||||
report := func(declared string) {
|
||||
body, _ := json.Marshal(Report{Node: "anchor", Declared: declared, Applied: []string{"store"}})
|
||||
if _, err := js.Context().Publish(ReportSubject("anchor"), body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
report("d1")
|
||||
eventually(t, "the holding controller hearing the report", func() bool { return old.count() == 1 })
|
||||
if fresh.count() != 0 {
|
||||
t.Fatal("the controller standing by acted on a report the holder was handed")
|
||||
}
|
||||
|
||||
stopOld()
|
||||
report("d2")
|
||||
eventually(t, "the second controller taking over once the first let go", func() bool { return fresh.count() == 1 })
|
||||
if old.count() != 1 {
|
||||
t.Errorf("the first controller heard %d reports", old.count())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user