Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73fa64ea68 | ||
|
|
1a13dbeb17 | ||
|
|
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
|
||||
}
|
||||
|
||||
@@ -254,10 +254,11 @@ func theResolver(t *testing.T) catalogue.Manifest {
|
||||
return m
|
||||
}
|
||||
|
||||
// The resolver is handed every machine on the private network as a wildcard, and is handed it again
|
||||
// when a machine leaves — through the module's own manifest asking for the fact, with no module of the
|
||||
// mesh's own in between (hal dnsmasq-app conversion, novox/hq 08-connectivity). It is the mesh's one
|
||||
// resolver (ADR 0194), and the container runtime is given no resolver of its own (ADR 0196).
|
||||
// The resolver is handed every machine on the private network as a wildcard, the same set and the
|
||||
// same source as the hosts file, and is handed it again when a machine leaves — through the
|
||||
// module's own manifest asking for the fact, with no module of the mesh's own in between (hal
|
||||
// dnsmasq-app conversion, novox/hq 08-connectivity). The runtime on that machine is pointed at the
|
||||
// machine's own address, where the resolver answers for its containers.
|
||||
func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
@@ -292,12 +293,11 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
|
||||
t.Errorf("the resolver's machines lack %q:\n%s", want, first)
|
||||
}
|
||||
}
|
||||
// The container runtime is given no resolver of its own (novox/hq ADR 0196): it copies its
|
||||
// machine's, which name the mesh's resolver first. A `dns` key would be a second account of where a
|
||||
// container asks, read only when the runtime starts.
|
||||
for _, r := range composed(t, open, "anchor").Resources {
|
||||
if r["id"] == "dnsmasq.runtime-dns" {
|
||||
t.Errorf("the resolver still writes the runtime's own dns: %v", r)
|
||||
if !strings.Contains(r["content"].(string), `"10.77.0.1"`) || r["into"] != "json" {
|
||||
t.Errorf("the runtime is not pointed at this machine's own address, written into its file: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -657,13 +657,6 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
machines[name] = at
|
||||
}
|
||||
|
||||
// And every zone a module in the mesh answers itself (novox/hq ADR 0199), for the mesh's resolver
|
||||
// to forward.
|
||||
zones, err := zonesInTheMesh(ctx, open)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
|
||||
// **The bus is never public** (novox/hq ADR 0169). It was a foundation port — widened from the
|
||||
// broker's own `from: mesh` to from-anywhere on the broker's host, so a machine could enrol
|
||||
// before it had an address on the private network. A machine joins through the tunnel now, and
|
||||
@@ -733,79 +726,14 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
BusMembership: memberships[node],
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
Machines: machines, Zones: zones,
|
||||
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
||||
Machines: machines,
|
||||
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
||||
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
|
||||
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built,
|
||||
BusUsers: busUsers,
|
||||
}, record, nil
|
||||
}
|
||||
|
||||
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
|
||||
// 0199): the zone settled from that node's settings, the node's private address, the port the
|
||||
// answering listen is published on there.
|
||||
//
|
||||
// Read across every machine's resolution, as the roster once read routed names: a node whose set does
|
||||
// not compose declares nothing and is passed over, so one broken machine does not cost the rest their
|
||||
// zones; a store that cannot be read is raised, naming the machine, because returning the zones
|
||||
// without it would withdraw them from the resolver as if the operator had (novox/hq 04-ISSUES/152).
|
||||
// What the mesh refuses about the zones together — one declared twice, one shadowing the mesh's
|
||||
// suffix or a node's public domain — is refused here, by name.
|
||||
func zonesInTheMesh(ctx context.Context, open *stores) ([]catalogue.ZoneAt, error) {
|
||||
inv := open.inventory
|
||||
places, err := inv.Overlays(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("where the machines are cannot be read: %w", err)
|
||||
}
|
||||
address := map[string]string{}
|
||||
for _, p := range places {
|
||||
if strings.TrimSpace(p.Address) != "" {
|
||||
address[p.Name] = p.Address
|
||||
}
|
||||
}
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err)
|
||||
}
|
||||
var zones []catalogue.ZoneAt
|
||||
var public []string
|
||||
for _, n := range nodes {
|
||||
plan, _, err := planFor(ctx, open, n.Name)
|
||||
switch {
|
||||
case unresolvable(err):
|
||||
continue
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("the zones %s answers cannot be read: %w", n.Name, err)
|
||||
}
|
||||
if plan.PublicDomain != "" {
|
||||
public = append(public, plan.PublicDomain)
|
||||
}
|
||||
for _, m := range plan.Modules {
|
||||
if m.Zone == nil {
|
||||
continue
|
||||
}
|
||||
at := address[n.Name]
|
||||
if at == "" {
|
||||
// Not on the private network yet: nothing could reach its answerer.
|
||||
continue
|
||||
}
|
||||
published, layers, err := portsGivenOn(ctx, inv, n.Name, m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err)
|
||||
}
|
||||
z, err := catalogue.ZoneOn(m, layers, published, n.Name, at)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zones = append(zones, *z)
|
||||
}
|
||||
}
|
||||
if problems := catalogue.ZonesProblems(zones, overlay.Suffix(), public); len(problems) > 0 {
|
||||
return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - "))
|
||||
}
|
||||
return zones, nil
|
||||
}
|
||||
|
||||
// certificateFor is what the mesh certifies about one machine's internal name.
|
||||
//
|
||||
// It reaches across two contexts and reads neither one's store from the other: `inventory` knows
|
||||
|
||||
@@ -288,7 +288,13 @@ 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
|
||||
plans, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
@@ -315,6 +321,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
|
||||
@@ -549,7 +558,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
|
||||
}
|
||||
}
|
||||
@@ -791,6 +801,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 +827,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}}}
|
||||
|
||||
+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",
|
||||
|
||||
@@ -21,9 +21,8 @@ import (
|
||||
// formats and gains no fields.
|
||||
//
|
||||
// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is:
|
||||
// `at`, `as`, `from` and `address` (the providing machine's private address, novox/hq ADR 0194) are
|
||||
// facts about any provision at all, and everything else comes from what the provider said it
|
||||
// serves — whose keys are agreed by the requirement's name, not by this file.
|
||||
// `at`, `as` and `from` are facts about any provision at all, and everything else comes from what
|
||||
// the provider said it serves — whose keys are agreed by the requirement's name, not by this file.
|
||||
|
||||
// bound is where a module says a value from one of its bindings belongs:
|
||||
// ${bound:<provision>.<key>}.
|
||||
|
||||
@@ -232,34 +232,3 @@ func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) {
|
||||
t.Fatal("one module on two machines shares an identity")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine's resolver configuration must name its resolver by address — it cannot resolve the name
|
||||
// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing
|
||||
// machine's private address beside its name, and only when the machine has one.
|
||||
func TestABindingOffersTheProvidersAddress(t *testing.T) {
|
||||
consumer := func() Resolution {
|
||||
return Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []Manifest{{
|
||||
Module: "resolv-conf",
|
||||
Requires: []string{"wildcard-resolution"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
|
||||
"content": "nameserver ${bound:wildcard-resolution:address}\n",
|
||||
}},
|
||||
}},
|
||||
Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}},
|
||||
}
|
||||
}
|
||||
out, err := consumer().Declaration(Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" {
|
||||
t.Fatalf("the resolver is not named by its address: %q", got)
|
||||
}
|
||||
// A machine with no address yet: refused, never written with a blank where the address belongs.
|
||||
if _, err := consumer().Declaration(Rendering{}); err == nil {
|
||||
t.Fatal("a file naming an address the mesh does not have was composed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,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 "+
|
||||
|
||||
@@ -155,10 +155,6 @@ type Rendering struct {
|
||||
// standing beside the machines and looking as real as they do.
|
||||
Machines map[string]string
|
||||
|
||||
// Zones is every zone a module in the mesh answers itself, where it is answered (novox/hq ADR
|
||||
// 0199): the mesh's resolver forwards each one there.
|
||||
Zones []ZoneAt
|
||||
|
||||
Settings SettingsBy
|
||||
Generators map[string]Generator
|
||||
// Grants are the credentials this node must create, for the provisions it offers. Passed in
|
||||
@@ -736,16 +732,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
known[provision] = values
|
||||
}
|
||||
}
|
||||
// And the providing machine's private address, beside its name (novox/hq ADR 0194). A name is
|
||||
// what nearly every consumer wants; the one that cannot use it is a machine's resolver
|
||||
// configuration, which must reach the resolver before it can resolve anything — the resolver's
|
||||
// own name included. Absent when the machine has no address yet, so a file naming it is refused
|
||||
// rather than written with a blank where an address belongs.
|
||||
for _, values := range known {
|
||||
if address := with.Machines[values["at"]]; address != "" {
|
||||
values["address"] = address
|
||||
}
|
||||
}
|
||||
// And what the module is called through each requirement it contributes to (novox/hq
|
||||
// 04-ISSUES/122) — the same composition its binding file carries.
|
||||
for provision, values := range known {
|
||||
@@ -915,7 +901,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
|
||||
// this module's name, so they are applied, reported and removed exactly as anything else
|
||||
// it declares.
|
||||
given, err := FactsWithZonesInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix, with.Zones)
|
||||
given, err := FactsInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
|
||||
@@ -540,10 +540,6 @@ type Manifest struct {
|
||||
// `restart-on` names to restart when the roster changes.
|
||||
Facts map[string]RosterFile `json:"facts,omitempty"`
|
||||
|
||||
// Zone is the zone of names this module answers itself, and the listen that answers it (novox/hq
|
||||
// ADR 0199). The mesh's resolver forwards the zone to it; nothing here names an address.
|
||||
Zone *Zone `json:"zone,omitempty"`
|
||||
|
||||
// Certificate is where this module wants a certificate for its machine's name inside the
|
||||
// mesh, and where the key that goes with it can be found.
|
||||
//
|
||||
@@ -761,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.
|
||||
@@ -1746,7 +1747,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
}
|
||||
}
|
||||
|
||||
problems = append(problems, zoneProblems(m)...)
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -48,12 +48,9 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
}
|
||||
for _, want := range []string{
|
||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
||||
// The private address and loopback, never a LAN's (novox/hq ADR 0194): a device that is not a
|
||||
// member cannot reach what the mesh's names point at.
|
||||
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
||||
// No hosts file and no operator's files: the mesh's resolver answers every node (ADR 0199).
|
||||
"\nno-hosts\n",
|
||||
"\nconf-file=" + m.Facts["zones"].Path + "\n",
|
||||
// Loopback is the mesh-wide setting's default; a machine answering its own LAN adds its
|
||||
// address there (novox/hq issue 198).
|
||||
"\nlisten-address=${setting:listen-addresses}\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
||||
"\ndomain-needed\n", "\nbogus-priv\n",
|
||||
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
||||
} {
|
||||
@@ -76,15 +73,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
t.Errorf("the resolver listens on %s", taken)
|
||||
}
|
||||
}
|
||||
// Never a directory or a file the operator keeps: a line written for one machine's programs would
|
||||
// become an answer for every node (ADR 0199).
|
||||
for _, never := range []string{"conf-dir=", "addn-hosts=", "listen-address=${setting:"} {
|
||||
if strings.Contains(config, never) {
|
||||
t.Errorf("the mesh's resolver still reads or listens on %q", never)
|
||||
}
|
||||
}
|
||||
// And the file that decides what the machine asks names the mesh's resolver first, by address,
|
||||
// and a public one second, asked only when the first is silent (ADR 0196).
|
||||
// And the file that decides what the machine asks names it there, alone.
|
||||
var resolv string
|
||||
for _, r := range catalogueManifest(t, "resolv-conf").Resources {
|
||||
if r["path"] == "/etc/resolv.conf" {
|
||||
@@ -97,16 +86,13 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
nameservers = append(nameservers, strings.TrimPrefix(line, "nameserver "))
|
||||
}
|
||||
}
|
||||
if len(nameservers) != 2 || nameservers[0] != "${bound:wildcard-resolution:address}" || nameservers[1] != "1.1.1.1" {
|
||||
t.Errorf("resolv.conf names %v; the mesh's resolver by address first, a public one second", nameservers)
|
||||
if len(nameservers) != 1 || nameservers[0] != "127.0.0.1" {
|
||||
t.Errorf("resolv.conf names %v; the predecessor's names the mesh's resolver alone at 127.0.0.1", nameservers)
|
||||
}
|
||||
if !strings.Contains(resolv, "\noptions timeout:1 attempts:1") {
|
||||
t.Errorf("the fallback is not reached after one short attempt:\n%s", resolv)
|
||||
}
|
||||
// The split-DNS alternative points at the same resolver, or a machine that keeps
|
||||
// The split-DNS alternative points at the same address, or a machine that keeps
|
||||
// systemd-resolved in charge would route the mesh's suffix to nothing.
|
||||
for _, r := range catalogueManifest(t, "resolved-split-dns").Resources {
|
||||
if content, _ := r["content"].(string); content != "" && !strings.Contains(content, "DNS=${bound:wildcard-resolution:address}\n") {
|
||||
if content, _ := r["content"].(string); content != "" && !strings.Contains(content, "DNS=127.0.0.1\n") {
|
||||
t.Errorf("resolved-split-dns does not point at the resolver's address:\n%s", content)
|
||||
}
|
||||
}
|
||||
@@ -114,8 +100,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
|
||||
// The resolver and what points the machine at it compose on one machine, and what arrives is the
|
||||
// mesh's account of every machine as a wildcard, the suffix kept local, the daemon restarting on
|
||||
// that file, the machine pointed at the resolver by address, and the runtime given no resolver of
|
||||
// its own but kept running across a restart (ADR 0196).
|
||||
// that file, and the runtime pointed at this machine's own address.
|
||||
func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
||||
got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "resolv-conf"},
|
||||
Node{Name: "anchor", At: "anchor.internal"}, World{})
|
||||
@@ -130,7 +115,6 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
||||
// issue 111) — the resolver's zones read only the second, and in this scenario the two
|
||||
// happen to be the same map, since nothing routed is part of it.
|
||||
Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||
Zones: []ZoneAt{{Zone: "incus", Address: "10.42.0.2", Port: 5353}},
|
||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
||||
})
|
||||
@@ -160,29 +144,24 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
||||
for _, id := range service["restart-on"].([]any) {
|
||||
reflects[id.(string)] = true
|
||||
}
|
||||
if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] || !reflects["dnsmasq.fact-zones"] {
|
||||
t.Errorf("the daemon does not restart on its configuration, the machines and the zones: %v", service["restart-on"])
|
||||
}
|
||||
if z, _ := ids["dnsmasq.fact-zones"]["content"].(string); !strings.Contains(z, "server=/incus/10.42.0.2#5353\n") {
|
||||
t.Errorf("the resolver was not told to forward the zone to its answerer:\n%s", z)
|
||||
if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] {
|
||||
t.Errorf("the daemon does not restart on its configuration and the machines file both: %v", service["restart-on"])
|
||||
}
|
||||
|
||||
// The runtime's own file, written into (novox/hq ADR 0102) with one key, by what decides how the
|
||||
// machine resolves: a restart keeps every container running. No `dns` — a container copies its
|
||||
// machine's resolvers (ADR 0196), and the mesh's resolver is not written into the runtime twice.
|
||||
if ids["dnsmasq.runtime-dns"] != nil {
|
||||
t.Errorf("the resolver still writes the runtime's dns: %v", ids["dnsmasq.runtime-dns"])
|
||||
}
|
||||
runtime := ids["resolv-conf.runtime-config"]
|
||||
// The runtime's own file, written into (novox/hq ADR 0102) with the keys this module states:
|
||||
// where containers resolve, and that a restart keeps them running — because the runtime reads
|
||||
// `dns` only when it starts, and the one restart that needs is the operator's (issue 110).
|
||||
runtime := ids["dnsmasq.runtime-dns"]
|
||||
if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" {
|
||||
t.Fatalf("live-restore is not written into the runtime's file: %v", runtime)
|
||||
t.Fatalf("the runtime's dns is not written into its file: %v", runtime)
|
||||
}
|
||||
var keys map[string]any
|
||||
if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil {
|
||||
t.Fatalf("the runtime's keys are not JSON: %v", err)
|
||||
}
|
||||
if len(keys) != 1 || keys["live-restore"] != true {
|
||||
t.Errorf("the runtime is given %v; live-restore and nothing else", keys)
|
||||
dns, _ := keys["dns"].([]any)
|
||||
if len(keys) != 2 || len(dns) != 1 || dns[0] != "10.42.0.1" || keys["live-restore"] != true {
|
||||
t.Errorf("the runtime is given %v; containers resolve at this machine's own private-network address, a restart keeps them, and nothing else is written", keys)
|
||||
}
|
||||
// The runtime is reloaded when that file changes, and never restarted: a restart stops every
|
||||
// container on the machine (ADR 0102), and a reload is what turns live-restore on.
|
||||
@@ -192,10 +171,10 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
if _, restarts := r["restart-on"]; restarts {
|
||||
t.Errorf("the runtime is ordered restarted, which stops every container (ADR 0102): %v", r)
|
||||
t.Errorf("the resolver orders the runtime restarted, which stops every container (ADR 0102): %v", r)
|
||||
}
|
||||
for _, on := range asStrings(r["reload-on"]) {
|
||||
if on == "resolv-conf.runtime-config" {
|
||||
if on == "dnsmasq.runtime-dns" {
|
||||
reloaded = true
|
||||
}
|
||||
}
|
||||
@@ -205,8 +184,8 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
||||
}
|
||||
|
||||
resolv := ids["resolv-conf.resolv"]
|
||||
if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 10.42.0.1\nnameserver 1.1.1.1\n") {
|
||||
t.Fatalf("the machine is not pointed at the resolver by address, with the public fallback: %v", resolv)
|
||||
if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 127.0.0.1\n") {
|
||||
t.Fatalf("the machine is not pointed at the resolver: %v", resolv)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,16 +61,6 @@ type rosterView struct {
|
||||
Suffix string
|
||||
Names []rosterEntry
|
||||
Machines []rosterEntry
|
||||
// Zones is every zone a module in the mesh answers itself, with where its answerer is (novox/hq
|
||||
// ADR 0199) — what the mesh's resolver forwards. Ordered by zone.
|
||||
Zones []rosterZone
|
||||
}
|
||||
|
||||
// rosterZone is one zone as a template sees it: the zone, and the address and port answering it.
|
||||
type rosterZone struct {
|
||||
Zone string
|
||||
Address string
|
||||
Port int
|
||||
}
|
||||
|
||||
// rosterEntry is one machine as a template sees it: its bare name, its full mesh name, its address,
|
||||
@@ -90,12 +80,6 @@ type rosterEntry struct {
|
||||
// `machines` is only the machines — the two must not be confused (novox/hq 04-ISSUES/111), so both
|
||||
// are given and the template chooses.
|
||||
func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string) ([]map[string]any, error) {
|
||||
return FactsWithZonesInto(m, r, every, machines, accounts, suffix, nil)
|
||||
}
|
||||
|
||||
// FactsWithZonesInto is FactsInto with the mesh's zones in the view, for a template that ranges them.
|
||||
func FactsWithZonesInto(m Manifest, r Resolution, every, machines, accounts map[string]string, suffix string,
|
||||
zones []ZoneAt) ([]map[string]any, error) {
|
||||
if len(m.Facts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -110,7 +94,6 @@ func FactsWithZonesInto(m Manifest, r Resolution, every, machines, accounts map[
|
||||
Suffix: strings.TrimPrefix(suffixOr(suffix), "."),
|
||||
Names: entriesFrom(every, accounts, suffix),
|
||||
Machines: entriesFrom(machines, accounts, suffix),
|
||||
Zones: zonesFrom(zones),
|
||||
}
|
||||
|
||||
out := make([]map[string]any, 0, len(names))
|
||||
@@ -240,13 +223,3 @@ func sortedNames(addresses map[string]string) []string {
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// zonesFrom is the zones a template ranges, ordered by zone so two renderings of one mesh are one file.
|
||||
func zonesFrom(zones []ZoneAt) []rosterZone {
|
||||
out := make([]rosterZone, 0, len(zones))
|
||||
for _, z := range zones {
|
||||
out = append(out, rosterZone{Zone: z.Zone, Address: z.Address, Port: z.Port})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Zone < out[j].Zone })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -107,31 +107,7 @@ var defaultSeats = []Seat{
|
||||
// that machine unresolvable in the meantime. Deleted once no registered manifest claims it.
|
||||
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0190"},
|
||||
// **The mesh's one resolver** (novox/hq ADR 0194, 0196): every node's internal domain, held in one
|
||||
// place, and every node and container asks it first. Delivers what a machine's resolver
|
||||
// configuration requires, so that requirement resolves to the holder wherever it is placed.
|
||||
{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution", Decision: "novox/hq ADR 0194"},
|
||||
// **Retired by ADR 0194, kept while a manifest still claims it** — the same reason as
|
||||
// mesh-build-machine above: a machine still holds it until the mesh's resolver replaces it, and
|
||||
// removing the row first would make that machine unresolvable. Deleted once nothing claims it.
|
||||
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||
// **A machine's /etc/hosts is one module's** (novox/hq ADR 0199): its holder writes the machine's
|
||||
// own lines and keeps every other line as the operator's, changed through these three verbs on that
|
||||
// machine alone. The controller holds none of it.
|
||||
{Name: "node-hosts-file", Scope: ScopeNode, Decision: "novox/hq ADR 0199",
|
||||
Serves: []Verb{
|
||||
{Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " +
|
||||
"the operator's, or the block of the module or tool that writes it.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " +
|
||||
"/etc/hosts — a name for this machine's own programs, not the mesh's.",
|
||||
Input: schema(map[string]string{"address": "the IPv4 or IPv6 address",
|
||||
"names": "the names for it, separated by spaces"}, []string{"address", "names"})},
|
||||
{Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " +
|
||||
"lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
||||
Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"},
|
||||
[]string{"name"})},
|
||||
}},
|
||||
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
|
||||
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
|
||||
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
|
||||
|
||||
@@ -44,10 +44,10 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Nineteen since mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199) — two fewer
|
||||
// once the retired mesh-build-machine and node-dns-resolver rows go, when no manifest claims either.
|
||||
if len(Seats()) != 19 {
|
||||
t.Errorf("the mesh defines %d seats rather than 19; the set is closed, so a change here is "+
|
||||
// Seventeen since node-build-agent (novox/hq ADR 0190) — sixteen once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it any more.
|
||||
if len(Seats()) != 17 {
|
||||
t.Errorf("the mesh defines %d seats rather than 17; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Zones: names a module answers itself (novox/hq ADR 0199).
|
||||
//
|
||||
// The mesh's resolver holds each node's internal domain and nothing else (ADR 0191, 0194). A module
|
||||
// whose names are its own — the lab's scenario machines, known only while a scenario runs — declares
|
||||
// the zone it answers and the listen that answers it; the controller hands the resolver's holder every
|
||||
// zone with the declaring node's private address and the port that listen is published on, and the
|
||||
// holder forwards the zone there. **A definition names no address** (ADR 0112): the zone is a setting,
|
||||
// the listen is the module's own, and where they are is the mesh's fact.
|
||||
|
||||
// Zone is the manifest's declaration that a module answers the names in one zone.
|
||||
type Zone struct {
|
||||
// Name is the zone: a label or a dotted name, normally `${setting:<key>}`, so the operator chooses
|
||||
// it and the definition does not.
|
||||
Name string `json:"name"`
|
||||
// Listen names one of the module's listens: the DNS answerer for the zone.
|
||||
Listen string `json:"listen"`
|
||||
}
|
||||
|
||||
// ZoneAt is a declared zone where the mesh placed it: what the resolver's holder forwards, and where.
|
||||
type ZoneAt struct {
|
||||
Zone string
|
||||
Node string
|
||||
Module string
|
||||
Address string
|
||||
Port int
|
||||
}
|
||||
|
||||
// zoneProblems is what is wrong with a module's zone declaration on its own, before any node.
|
||||
func zoneProblems(m Manifest) []string {
|
||||
if m.Zone == nil {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
if strings.TrimSpace(m.Zone.Name) == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s declares a zone with no name", m.Module))
|
||||
}
|
||||
if !m.hasListen(m.Zone.Listen) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares zone %q answered by listen %q, and has no listen of that name",
|
||||
m.Module, m.Zone.Name, m.Zone.Listen))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func (m Manifest) hasListen(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, l := range m.Listens {
|
||||
if l.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ZoneOn is one module's zone as one node places it: the name settled from the node's settings, the
|
||||
// port its answering listen is published on there. Nothing when the module declares no zone.
|
||||
func ZoneOn(m Manifest, layers []Layer, published map[int]int, node, address string) (*ZoneAt, error) {
|
||||
if m.Zone == nil {
|
||||
return nil, nil
|
||||
}
|
||||
settled, err := Settle(map[string]any{"zone": m.Zone.Name}, layers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s's zone on %s: %w", m.Module, node, err)
|
||||
}
|
||||
zone := strings.Trim(strings.ToLower(fmt.Sprint(settled["zone"])), ".")
|
||||
var port int
|
||||
for _, l := range m.Listens {
|
||||
if l.Name == m.Zone.Listen {
|
||||
port = l.Port
|
||||
if at, given := published[l.Port]; given {
|
||||
port = at
|
||||
}
|
||||
}
|
||||
}
|
||||
return &ZoneAt{Zone: zone, Node: node, Module: m.Module, Address: address, Port: port}, nil
|
||||
}
|
||||
|
||||
// ZonesProblems is what the mesh refuses about its zones together: one zone declared twice, a zone
|
||||
// that is the mesh's suffix or under it, a zone that is a node's public domain or under one. A module
|
||||
// may not shadow names the mesh's resolver or the public DNS answers.
|
||||
func ZonesProblems(zones []ZoneAt, suffix string, publicDomains []string) []string {
|
||||
var problems []string
|
||||
under := func(zone, domain string) bool {
|
||||
domain = strings.Trim(strings.ToLower(domain), ".")
|
||||
return domain != "" && (zone == domain || strings.HasSuffix(zone, "."+domain))
|
||||
}
|
||||
seen := map[string]ZoneAt{}
|
||||
for _, z := range zones {
|
||||
if other, twice := seen[z.Zone]; twice && (other.Node != z.Node || other.Module != z.Module) {
|
||||
problems = append(problems, fmt.Sprintf("zone %q is declared by %s on %s and by %s on %s; one module answers a zone",
|
||||
z.Zone, other.Module, other.Node, z.Module, z.Node))
|
||||
}
|
||||
seen[z.Zone] = z
|
||||
if under(z.Zone, suffix) {
|
||||
problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the mesh's own suffix or under it",
|
||||
z.Module, z.Node, z.Zone))
|
||||
}
|
||||
for _, d := range publicDomains {
|
||||
if under(z.Zone, d) {
|
||||
problems = append(problems, fmt.Sprintf("%s on %s declares zone %q, which is the public domain %q or under it",
|
||||
z.Module, z.Node, z.Zone, d))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A zone names the listen that answers it, or there is nothing to forward to (novox/hq ADR 0199).
|
||||
func TestAZoneMustNameOneOfTheModulesListens(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"lab","version":"1",
|
||||
"listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}],
|
||||
"zone":{"name":"${setting:zone}","listen":"web"}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), `has no listen of that name`) {
|
||||
t.Fatalf("a zone answered by a listen the module does not have was accepted: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"lab","version":"1",
|
||||
"listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}],
|
||||
"zone":{"name":"${setting:zone}","listen":"dns"}}`)); err != nil {
|
||||
t.Fatalf("a well-formed zone was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The zone is the operator's (a setting) and the port is where this machine publishes the listen —
|
||||
// neither is the definition's to state.
|
||||
func TestAZoneIsPlacedFromTheNodesSettingAndPublishedPort(t *testing.T) {
|
||||
m := Manifest{Module: "lab", Zone: &Zone{Name: "${setting:zone}", Listen: "dns"},
|
||||
Listens: []Listening{{Name: "dns", Port: 5353, From: FromMesh}}}
|
||||
z, err := ZoneOn(m, []Layer{{From: "node", Values: map[string]any{"zone": "Incus."}}},
|
||||
map[int]int{5353: 15353}, "workstation", "10.77.0.3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if z.Zone != "incus" || z.Address != "10.77.0.3" || z.Port != 15353 || z.Node != "workstation" {
|
||||
t.Fatalf("the zone was placed as %+v", *z)
|
||||
}
|
||||
if _, err := ZoneOn(m, nil, nil, "workstation", "10.77.0.3"); err == nil {
|
||||
t.Fatal("a zone nobody named was placed")
|
||||
}
|
||||
}
|
||||
|
||||
// One module answers a zone, and none may shadow the mesh's names or a public domain.
|
||||
func TestTheMeshRefusesAZoneTwiceOrOneThatShadows(t *testing.T) {
|
||||
one := ZoneAt{Zone: "incus", Node: "workstation", Module: "lab", Address: "10.77.0.3", Port: 53}
|
||||
if p := ZonesProblems([]ZoneAt{one}, "internal", []string{"example.tld"}); len(p) != 0 {
|
||||
t.Fatalf("one ordinary zone was refused: %v", p)
|
||||
}
|
||||
twice := one
|
||||
twice.Node, twice.Module = "laptop", "other"
|
||||
cases := map[string][]ZoneAt{
|
||||
"declared by": {one, twice},
|
||||
"mesh's own suffix": {{Zone: "lab.internal", Node: "a", Module: "m"}},
|
||||
"public domain": {{Zone: "dev.example.tld", Node: "a", Module: "m"}},
|
||||
}
|
||||
for want, zones := range cases {
|
||||
p := strings.Join(ZonesProblems(zones, "internal", []string{"example.tld"}), "\n")
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("not refused for %q: %q", want, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The resolver's template sees every zone with where it is answered, in zone order.
|
||||
func TestTheResolversTemplateRangesTheZones(t *testing.T) {
|
||||
m := Manifest{Module: "dnsmasq", Facts: map[string]RosterFile{"zones": {
|
||||
Path: "/etc/mesh-resolver/zones.conf",
|
||||
Template: "{{range .Zones}}server=/{{.Zone}}/{{.Address}}#{{.Port}}\n{{end}}",
|
||||
}}}
|
||||
out, err := FactsWithZonesInto(m, Resolution{Node: "anchor"}, nil, nil, nil, "", []ZoneAt{
|
||||
{Zone: "zeta", Address: "10.77.0.2", Port: 53},
|
||||
{Zone: "incus", Address: "10.77.0.3", Port: 15353},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out[0]["content"]; got != "server=/incus/10.77.0.3#15353\nserver=/zeta/10.77.0.2#53\n" {
|
||||
t.Fatalf("the resolver was told %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user