Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e56416fa8c | ||
|
|
cadf74a176 | ||
|
|
74efe8e2e7 | ||
|
|
68af9eff44 | ||
|
|
518eeb7941 | ||
|
|
bf2da878a0 | ||
|
|
50cf253a43 | ||
|
|
980a0dee93 | ||
|
|
ac9c2d57be | ||
|
|
8b016cc62b | ||
|
|
6784efae75 | ||
|
|
d86baebe9a |
@@ -530,6 +530,15 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
||||
if result.Source != nil && result.Source.Seat != "" {
|
||||
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
|
||||
}
|
||||
// **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215):
|
||||
// the commit is built and recorded as what it was built from, and the module keeps following
|
||||
// what it followed before — the repository's default branch for one new to the catalogue.
|
||||
if followedBranch(result.Ref) == "" && result.Ref != "" {
|
||||
recorded.Ref = ""
|
||||
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil {
|
||||
recorded.Ref = followedBranch(was.Ref)
|
||||
}
|
||||
}
|
||||
if err := namesNoInstallation(manifest); err != nil {
|
||||
return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
|
||||
result.On, result.Repository, short(result.Commit), err)
|
||||
|
||||
@@ -63,3 +63,40 @@ func TestABuildHeardIsRecordedAndRegistered(t *testing.T) {
|
||||
t.Fatalf("a failure is said in the builder's words: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/215: a build asked at a commit is recorded as built from that commit, and the
|
||||
// module keeps following the branch it followed — a new one, the default branch.
|
||||
func TestABuildAtACommitKeepsTheBranchTheModuleFollows(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
manifest, _ := json.Marshal(map[string]any{"module": "unifi", "version": "1"})
|
||||
result := func(id, ref, commit string) link.BuildResult {
|
||||
return link.BuildResult{ID: id, Repository: "http://forge.internal:20000/novox/mesh-catalog.git",
|
||||
Path: "modules/unifi", Ref: ref, On: "anchor", Commit: commit, Manifest: manifest,
|
||||
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
||||
}
|
||||
if _, _, err := takeIn(ctx, open.inventory, result("b-1", "main", "1111111aaaa")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := takeIn(ctx, open.inventory, result("b-2", "9c97a8a", "9c97a8a1d2c3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src, err := open.inventory.SourceOf(ctx, "unifi")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src.Ref != "main" || src.BuiltFrom != "9c97a8a1d2c3" {
|
||||
t.Errorf("after a build at a commit the module follows %q, built from %q; want main, 9c97a8a1d2c3", src.Ref, src.BuiltFrom)
|
||||
}
|
||||
|
||||
// One new to the catalogue, first built at a commit, follows the default branch.
|
||||
other, _ := json.Marshal(map[string]any{"module": "letta", "version": "1"})
|
||||
r := result("b-3", "deadbeef", "deadbeefcafe")
|
||||
r.Manifest, r.Path = other, "modules/letta"
|
||||
if _, _, err := takeIn(ctx, open.inventory, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if src, _ := open.inventory.SourceOf(ctx, "letta"); src.Ref != "" {
|
||||
t.Errorf("a module first built at a commit follows %q, want the default branch", src.Ref)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,11 +254,10 @@ func theResolver(t *testing.T) catalogue.Manifest {
|
||||
return m
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
@@ -266,6 +265,12 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
|
||||
if _, err := assign(ctx, open, "anchor", "dnsmasq"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Its bus credential, as assigning issues it where the bus is reachable (novox/hq issue 203):
|
||||
// no bus is known to this test, so it is minted here, or composing refuses the placeholder.
|
||||
if _, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{
|
||||
Username: "anchor.dnsmasq", Kind: inventory.BusModule, Node: "anchor", Module: "dnsmasq"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zones := func() string {
|
||||
t.Helper()
|
||||
for _, r := range composed(t, open, "anchor").Resources {
|
||||
@@ -287,11 +292,12 @@ 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" {
|
||||
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)
|
||||
}
|
||||
t.Errorf("the resolver still writes the runtime's own dns: %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -211,3 +211,27 @@ func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/215: a module once built at a commit still follows its branch — a merge into it
|
||||
// matches the module, and a plan re-asks the branch, not the old commit.
|
||||
func TestAModuleBuiltAtACommitStillFollowsItsBranch(t *testing.T) {
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main"}
|
||||
pinned := inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "9c97a8a"}
|
||||
if !sourceIs(pinned, m) {
|
||||
t.Error("a module whose record names a commit is left out of a merge into its branch")
|
||||
}
|
||||
full := inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "9c97a8a1d2c3b4a5f60718293a4b5c6d7e8f9012"}
|
||||
if !sourceIs(full, m) {
|
||||
t.Error("a full commit hash is read as a branch")
|
||||
}
|
||||
if got := followedBranch("9c97a8a"); got != "" {
|
||||
t.Errorf("a plan would re-ask the old commit %q", got)
|
||||
}
|
||||
if got := followedBranch("release"); got != "release" {
|
||||
t.Errorf("a branch is not followed as named: %q", got)
|
||||
}
|
||||
// A module that follows another branch is still not this merge's.
|
||||
if sourceIs(inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Ref: "release"}, m) {
|
||||
t.Error("a module following another branch was matched")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,6 +657,13 @@ 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
|
||||
@@ -726,14 +733,79 @@ 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,
|
||||
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
||||
Machines: machines, Zones: zones,
|
||||
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
|
||||
|
||||
@@ -272,7 +272,8 @@ func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) e
|
||||
}
|
||||
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||
fmt.Printf(" tier %d: ", p.Tier)
|
||||
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, 0); err != nil {
|
||||
// The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215).
|
||||
if err := buildOne(ctx, source, e.Source.Path, followedBranch(e.Source.Ref), 0); err != nil {
|
||||
state.State = "failed"
|
||||
state.Why = err.Error()
|
||||
p.State = inventory.PlanFailed
|
||||
@@ -399,6 +400,24 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
// **Asked: settle from the build records first** (novox/hq 04-ISSUES/214). An outcome is taken
|
||||
// in by whichever controller hears it, and a merge to the controller's own repository replaces
|
||||
// the controller in its first tier: the build that produced the new one is recorded, and the
|
||||
// plan never hears it. The record is the fact; a build recorded after the ask is that tier's
|
||||
// outcome, whoever was listening.
|
||||
recorded := map[string][]inventory.Build{}
|
||||
for _, m := range tier {
|
||||
if s := p.Modules[m]; s != nil && s.State == "asked" {
|
||||
builds, err := inv.Builds(ctx, m, 5)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
recorded[m] = builds
|
||||
}
|
||||
}
|
||||
if settleFromRecords(p, tier, recorded) {
|
||||
return true, nil
|
||||
}
|
||||
// Asked: wait for every build.
|
||||
var latest time.Time
|
||||
for _, m := range tier {
|
||||
@@ -755,3 +774,41 @@ func splitList(s string) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// settleFromRecords marks every module of the tier still `asked` built — or failed — from a build
|
||||
// recorded after it was asked, and says whether it changed anything (novox/hq 04-ISSUES/214).
|
||||
// Newest first, as Builds answers: the first record after the ask is the outcome of that ask.
|
||||
func settleFromRecords(p *inventory.Plan, tier []string, recorded map[string][]inventory.Build) bool {
|
||||
changed := false
|
||||
for _, m := range tier {
|
||||
s := p.Modules[m]
|
||||
if s == nil || s.State != "asked" || s.AskedAt == nil {
|
||||
continue
|
||||
}
|
||||
var outcome *inventory.Build
|
||||
for i := range recorded[m] {
|
||||
b := recorded[m][i]
|
||||
if b.At.Before(*s.AskedAt) {
|
||||
break
|
||||
}
|
||||
outcome = &b
|
||||
}
|
||||
if outcome == nil {
|
||||
continue
|
||||
}
|
||||
at := outcome.At
|
||||
if outcome.Worked() {
|
||||
s.State = "built"
|
||||
s.BuiltAt = &at
|
||||
s.Commit = outcome.Commit
|
||||
} else {
|
||||
s.State = "failed"
|
||||
s.Why = outcome.Failed
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = fmt.Sprintf("%s failed to build in tier %d", m, p.Tier)
|
||||
}
|
||||
fmt.Printf("%s: %s settled from the build records as %s (%s)\n", p.ID, m, s.State, outcome.ID)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -126,3 +126,40 @@ func TestABundleIsPlannedAfterTheToolchainItIsCompiledIn(t *testing.T) {
|
||||
t.Fatalf("the toolchain, then the bundle: %v", p.Tiers)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/214: a plan whose build outcome was recorded while no controller followed it —
|
||||
// the controller rebuilding itself — settles from the build records instead of waiting for ever.
|
||||
func TestAPlanSettlesAnAskedBuildFromTheRecords(t *testing.T) {
|
||||
asked := time.Date(2026, 10, 3, 19, 20, 0, 0, time.UTC)
|
||||
p := inventory.Plan{ID: "plan-1", Tiers: [][]string{{"mesh-controller", "builder"}, {"route-proxy"}},
|
||||
Modules: map[string]*inventory.PlanModule{
|
||||
"mesh-controller": {State: "asked", AskedAt: &asked},
|
||||
"builder": {State: "asked", AskedAt: &asked},
|
||||
}}
|
||||
records := map[string][]inventory.Build{
|
||||
// Newest first, as Builds answers: the build after the ask is the outcome.
|
||||
"mesh-controller": {
|
||||
{ID: "build-2", Commit: "2ebbb799", At: asked.Add(4 * time.Minute)},
|
||||
{ID: "build-1", Commit: "06ea2168", At: asked.Add(-10 * time.Minute)},
|
||||
},
|
||||
// Only a build from before the ask: not this ask's outcome.
|
||||
"builder": {{ID: "build-0", Commit: "06ea2168", At: asked.Add(-time.Hour)}},
|
||||
}
|
||||
if !settleFromRecords(&p, p.Tiers[0], records) {
|
||||
t.Fatal("nothing settled, though the controller's build is recorded after the ask")
|
||||
}
|
||||
if s := p.Modules["mesh-controller"]; s.State != "built" || s.Commit != "2ebbb799" || s.BuiltAt == nil {
|
||||
t.Errorf("the controller's ask is %+v, want built from 2ebbb799", s)
|
||||
}
|
||||
if s := p.Modules["builder"]; s.State != "asked" {
|
||||
t.Errorf("an ask with no record after it was settled: %+v", s)
|
||||
}
|
||||
|
||||
// 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}}}
|
||||
settleFromRecords(&q, q.Tiers[0], map[string][]inventory.Build{"x": {{ID: "b", Failed: "no", At: asked.Add(time.Minute)}}})
|
||||
if q.State != inventory.PlanFailed || q.Modules["x"].State != "failed" {
|
||||
t.Errorf("a recorded failure did not fail the plan: %+v %+v", q, q.Modules["x"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -283,6 +284,14 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
||||
packaging = nil
|
||||
}
|
||||
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
|
||||
// another branch is not part of this merge, and whoever is waiting for its change should read why.
|
||||
for _, e := range entries {
|
||||
if sameRepository(e.Source.Repository, m) && !sourceIs(e.Source, m) {
|
||||
fmt.Printf(" %s is built from %s/%s and follows %s, not %s; this merge leaves it out\n",
|
||||
e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base)
|
||||
}
|
||||
}
|
||||
touched := whatTheMergeTouched(from, entries, m)
|
||||
for _, e := range touched {
|
||||
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
||||
@@ -345,7 +354,24 @@ func sourceIs(s inventory.Source, m link.SourceMoved) bool {
|
||||
if !sameRepository(s.Repository, m) {
|
||||
return false
|
||||
}
|
||||
return s.Ref == "" || s.Ref == m.Base
|
||||
ref := followedBranch(s.Ref)
|
||||
return ref == "" || ref == m.Base
|
||||
}
|
||||
|
||||
// commitRef is a ref that names a commit rather than a branch: what `build --ref <commit>` asks for.
|
||||
var commitRef = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
|
||||
|
||||
// followedBranch is the branch a recorded ref means a module follows (novox/hq 04-ISSUES/215). **A
|
||||
// commit is never a branch to follow.** A build asked at a commit — to try one, or to pin it during a
|
||||
// fix — recorded that commit as the module's ref; every merge after it then failed to match the
|
||||
// module, its plan left it out without saying so, and every plan that rebuilt it asked for that same
|
||||
// old commit again. A commit recorded so is read as the repository's default branch, which is what
|
||||
// the module followed before it; a branch is followed as named.
|
||||
func followedBranch(ref string) string {
|
||||
if commitRef.MatchString(strings.TrimSpace(ref)) {
|
||||
return ""
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// sameRepository is whether a recorded repository is the one a merge names, in either spelling it
|
||||
|
||||
+18
-2
@@ -469,8 +469,24 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// request once, so the runtime announces everything it carries under its own name.
|
||||
sub = append(sub, announcing(append([]string{RuntimeModule}, serves...)...)...)
|
||||
pub = append(pub, discovering()...)
|
||||
// Nothing about consumers: it consumes nothing. A module's reactions to events are its
|
||||
// own long-lived process, which ADR 0175 leaves where it is; what moves here is tools.
|
||||
// **And it consumes for the modules it carries** (novox/hq ADR 0198, which changes ADR 0175's
|
||||
// "it consumes nothing"): a module's long-running code is a bundle this runtime launches, and
|
||||
// the runtime is its bus — it reads the module's own durable consumer and acknowledges what
|
||||
// the module's code took. Exactly the grants the module's own principal has for that consumer,
|
||||
// on its name and no other's: asking about it, pulling from it, acknowledging it. The
|
||||
// consumer is still the controller's to make, from the module's own principal.
|
||||
for _, d := range p.Carries {
|
||||
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches}
|
||||
if _, consumes := ConsumerFor(own); !consumes {
|
||||
continue
|
||||
}
|
||||
stream, durable := consumerStream(own), consumerDurable(own)
|
||||
pub = append(pub,
|
||||
"$JS.API.CONSUMER.INFO."+stream+"."+durable,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable,
|
||||
"$JS.ACK."+stream+"."+durable+".>")
|
||||
}
|
||||
sub = unique(sub)
|
||||
pub = unique(pub)
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ func TestAModulePullsItsOwnConsumerAndNoOthers(t *testing.T) {
|
||||
// their tools (novox/hq ADR 0175): every carried module's tool namespace, every held seat's verbs
|
||||
// on this node, every module's membership on this node, and a call to anything. Nothing it
|
||||
// consumes, because it reacts to nothing.
|
||||
func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) {
|
||||
func TestTheRuntimeServesTheUnionAndConsumesForItsModules(t *testing.T) {
|
||||
filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}}
|
||||
p := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{
|
||||
{Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}},
|
||||
@@ -408,22 +408,33 @@ func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) {
|
||||
t.Errorf("the runtime may not publish %s: %v", want, perms.Publish)
|
||||
}
|
||||
}
|
||||
// Nothing of what a carried module consumes, and no consumer of its own to ack.
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") {
|
||||
t.Errorf("the runtime was granted a delivery it has no consumer for: %s", s)
|
||||
// It reads the consumer of every carried module that consumes — that module's, by its name, as
|
||||
// the module's own principal could (novox/hq ADR 0198) — and of no module that consumes nothing.
|
||||
for _, want := range []string{
|
||||
"$JS.API.CONSUMER.INFO.EVENTS.anchor_zsh",
|
||||
"$JS.API.CONSUMER.MSG.NEXT.EVENTS.anchor_zsh",
|
||||
"$JS.ACK.EVENTS.anchor_zsh.>",
|
||||
} {
|
||||
if !contains(perms.Publish, want) {
|
||||
t.Errorf("the runtime may not read zsh's consumer: %s missing from %v", want, perms.Publish)
|
||||
}
|
||||
}
|
||||
for _, s := range perms.Publish {
|
||||
if strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER") {
|
||||
t.Errorf("the runtime was granted a consumer's subject and has no consumer: %s", s)
|
||||
if (strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER")) && !strings.Contains(s, "anchor_zsh") {
|
||||
t.Errorf("the runtime was granted a consumer no carried module of it consumes on: %s", s)
|
||||
}
|
||||
}
|
||||
// It pulls; nothing is pushed to it, and it subscribes no event subject directly.
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") {
|
||||
t.Errorf("the runtime was granted a delivery: %s", s)
|
||||
}
|
||||
}
|
||||
if !perms.AllowResponses {
|
||||
t.Error("the runtime answers what it is asked, and may not reply")
|
||||
}
|
||||
if _, needed := ConsumerFor(p); needed {
|
||||
t.Error("a consumer would be made for the runtime, which consumes nothing")
|
||||
t.Error("a consumer would be made for the runtime itself; it reads its modules' consumers, never one of its own")
|
||||
}
|
||||
// Each subject once in each list: the file is read as the mesh's authority model. One subject may
|
||||
// stand in both — the runtime answers discovery on `$SRV.INFO` and, as the console, asks it
|
||||
|
||||
@@ -21,8 +21,9 @@ import (
|
||||
// formats and gains no fields.
|
||||
//
|
||||
// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is:
|
||||
// `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.
|
||||
// `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.
|
||||
|
||||
// bound is where a module says a value from one of its bindings belongs:
|
||||
// ${bound:<provision>.<key>}.
|
||||
|
||||
@@ -232,3 +232,34 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,12 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
loads := append([]string(nil), a.Loads...)
|
||||
if a.Loads == nil && len(m.Tools) > 0 {
|
||||
loads = append([]string(nil), a.Entrypoints...)
|
||||
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
|
||||
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
|
||||
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
loads = []string{bin}
|
||||
}
|
||||
}
|
||||
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
|
||||
// it reached it by, and a manifest carrying that address names an installation —
|
||||
@@ -242,6 +248,11 @@ func (b *Build) problems(module string) []string {
|
||||
for _, e := range a.Entrypoints {
|
||||
found = found || e == load
|
||||
}
|
||||
// A bundle compiled to a binary is one executable: the runtime loads that or nothing
|
||||
// (novox/hq ADR 0193).
|
||||
if bin := BinaryOf(a); bin != "" {
|
||||
found = load == bin
|
||||
}
|
||||
if !found {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
|
||||
|
||||
@@ -155,6 +155,10 @@ 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
|
||||
@@ -732,6 +736,16 @@ 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 {
|
||||
@@ -901,7 +915,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 := FactsInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix)
|
||||
given, err := FactsWithZonesInto(m, r, with.Names, with.Machines, with.Accounts, with.Suffix, with.Zones)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -540,6 +540,10 @@ 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.
|
||||
//
|
||||
@@ -1742,6 +1746,7 @@ 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",
|
||||
|
||||
@@ -48,9 +48,12 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
}
|
||||
for _, want := range []string{
|
||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\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",
|
||||
// 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",
|
||||
"\ndomain-needed\n", "\nbogus-priv\n",
|
||||
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
||||
} {
|
||||
@@ -73,7 +76,15 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
t.Errorf("the resolver listens on %s", taken)
|
||||
}
|
||||
}
|
||||
// And the file that decides what the machine asks names it there, alone.
|
||||
// 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).
|
||||
var resolv string
|
||||
for _, r := range catalogueManifest(t, "resolv-conf").Resources {
|
||||
if r["path"] == "/etc/resolv.conf" {
|
||||
@@ -86,13 +97,16 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
nameservers = append(nameservers, strings.TrimPrefix(line, "nameserver "))
|
||||
}
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
// The split-DNS alternative points at the same address, or a machine that keeps
|
||||
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
|
||||
// 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=127.0.0.1\n") {
|
||||
if content, _ := r["content"].(string); content != "" && !strings.Contains(content, "DNS=${bound:wildcard-resolution:address}\n") {
|
||||
t.Errorf("resolved-split-dns does not point at the resolver's address:\n%s", content)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +114,8 @@ 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, and the runtime pointed at this machine's own address.
|
||||
// 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).
|
||||
func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
||||
got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "resolv-conf"},
|
||||
Node{Name: "anchor", At: "anchor.internal"}, World{})
|
||||
@@ -115,6 +130,7 @@ 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"}}}},
|
||||
})
|
||||
@@ -144,24 +160,29 @@ 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"] {
|
||||
t.Errorf("the daemon does not restart on its configuration and the machines file both: %v", service["restart-on"])
|
||||
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)
|
||||
}
|
||||
|
||||
// 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"]
|
||||
// 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"]
|
||||
if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" {
|
||||
t.Fatalf("the runtime's dns is not written into its file: %v", runtime)
|
||||
t.Fatalf("live-restore is not written into the runtime's 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)
|
||||
}
|
||||
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)
|
||||
if len(keys) != 1 || keys["live-restore"] != true {
|
||||
t.Errorf("the runtime is given %v; live-restore and nothing else", 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.
|
||||
@@ -171,10 +192,10 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
if _, restarts := r["restart-on"]; restarts {
|
||||
t.Errorf("the resolver orders the runtime restarted, which stops every container (ADR 0102): %v", r)
|
||||
t.Errorf("the runtime is ordered restarted, which stops every container (ADR 0102): %v", r)
|
||||
}
|
||||
for _, on := range asStrings(r["reload-on"]) {
|
||||
if on == "dnsmasq.runtime-dns" {
|
||||
if on == "resolv-conf.runtime-config" {
|
||||
reloaded = true
|
||||
}
|
||||
}
|
||||
@@ -184,8 +205,8 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
||||
}
|
||||
|
||||
resolv := ids["resolv-conf.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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@ 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,
|
||||
@@ -80,6 +90,12 @@ 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
|
||||
}
|
||||
@@ -94,6 +110,7 @@ func FactsInto(m Manifest, r Resolution, every, machines, accounts map[string]st
|
||||
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))
|
||||
@@ -223,3 +240,13 @@ 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
|
||||
}
|
||||
|
||||
@@ -414,3 +414,46 @@ func TestABundleNothingDeliversIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0193: a Go tools bundle is served — its binary is what the runtime starts, delivered
|
||||
// like any tools bundle, named to the runtime where a TypeScript bundle names its launcher.
|
||||
func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
lamp := Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/lamp-tools"}}}}
|
||||
if p := lamp.Build.problems("lamp"); len(p) != 0 {
|
||||
t.Fatalf("a Go tools bundle was refused: %v", p)
|
||||
}
|
||||
lamp, err := lamp.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "lamp/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(lamp.Bundles[0].Loads) != "[lamp-tools]" {
|
||||
t.Fatalf("the runtime loads %v from a Go bundle, want its binary", lamp.Bundles[0].Loads)
|
||||
}
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{lamp, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fileNamed(out, "lamp."+BundleID("tools")) == nil {
|
||||
t.Errorf("the Go bundle is not delivered: %v", ids(out))
|
||||
}
|
||||
env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
|
||||
if env[RuntimeToolModules] != "lamp="+BundlePath("lamp", "tools")+"/lamp-tools" {
|
||||
t.Errorf("the runtime is told %q, want the binary", env[RuntimeToolModules])
|
||||
}
|
||||
|
||||
// An artifact may say it explicitly; naming anything but the binary is refused.
|
||||
said := Manifest{Module: "lamp", Version: "1", Build: &Build{Artifacts: []Artifact{{Name: "tools",
|
||||
Kind: ArtifactBundle, Language: "go", System: "arch", Binary: "lamp", Loads: []string{"lamp"}}}}}
|
||||
if p := said.Build.problems("lamp"); len(p) != 0 {
|
||||
t.Errorf("loads naming the binary was refused: %v", p)
|
||||
}
|
||||
said.Build.Artifacts[0].Loads = []string{"tools/index.js"}
|
||||
if p := said.Build.problems("lamp"); len(p) == 0 {
|
||||
t.Error("a Go bundle loading a file it does not contain was admitted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,31 @@ 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
|
||||
}
|
||||
}
|
||||
// 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 "+
|
||||
// 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 "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,15 @@ func TestAPersonMayCallToolsAndNothingElse(t *testing.T) {
|
||||
}
|
||||
// The one tool, both ways it is addressed (novox/hq ADR 0159): to whichever instance
|
||||
// answers, and to the instance on one machine. Nothing else.
|
||||
if len(perms.Publish) != 2 || perms.Publish[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" ||
|
||||
perms.Publish[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" {
|
||||
// And asking what answers (novox/hq ADR 0197), which claims nothing and calls nothing.
|
||||
var tools []string
|
||||
for _, s := range perms.Publish {
|
||||
if !strings.HasPrefix(s, "$SRV.") {
|
||||
tools = append(tools, s)
|
||||
}
|
||||
}
|
||||
if len(tools) != 2 || tools[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" ||
|
||||
tools[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" {
|
||||
t.Errorf("ada may publish %v, which should be the one tool, both ways addressed, and nothing else", perms.Publish)
|
||||
}
|
||||
for _, s := range perms.Publish {
|
||||
|
||||
Reference in New Issue
Block a user