Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6784efae75 | ||
|
|
796f6410c1 | ||
|
|
e67c58cd98 | ||
|
|
85873b19e1 | ||
|
|
2ebbb79937 | ||
|
|
9204190445 | ||
|
|
06ea2168d8 | ||
|
|
2b149dd43e | ||
|
|
17dba2a34c | ||
|
|
11e4bc0ba1 | ||
|
|
e56f3aa1cb | ||
|
|
f966693583 | ||
|
|
5acc763992 | ||
|
|
4f009fff83 | ||
|
|
f27e31954f | ||
|
|
62650cd48c | ||
|
|
408f6dbad9 | ||
|
|
eae0577567 | ||
|
|
de26918c52 | ||
|
|
e01d18e548 | ||
|
|
59166b1031 | ||
|
|
c294949f2a | ||
|
|
f86f6a74f0 |
@@ -530,6 +530,15 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
if result.Source != nil && result.Source.Seat != "" {
|
if result.Source != nil && result.Source.Seat != "" {
|
||||||
recorded.Repository, recorded.Seat = result.Source.Repository, 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 {
|
if err := namesNoInstallation(manifest); err != nil {
|
||||||
return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
|
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)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A declaration composed earlier is numbered lower than one composed later, whatever order the two
|
||||||
|
// are sent in (novox/hq issue 204). The number used to be taken at send time, after composing, so a
|
||||||
|
// declaration composed before an assignment changed and sent after a newer one carried the higher
|
||||||
|
// number — and the machine, which refuses a lower number, took the older content as the mesh's
|
||||||
|
// newest word. Taken before the composition reads anything, the order of numbers is the order of
|
||||||
|
// compositions, and the host's refusal does what it is for.
|
||||||
|
func TestADeclarationComposedEarlierIsNumberedLowerWhateverOrderItIsSent(t *testing.T) {
|
||||||
|
allot := numbered()
|
||||||
|
var composed []string
|
||||||
|
compose := func(stamp string) func(string) (sendable, error) {
|
||||||
|
return func(node string) (sendable, error) {
|
||||||
|
composed = append(composed, stamp)
|
||||||
|
return sendable{Resources: []map[string]any{{"id": node + "." + stamp}}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Composed first — before an assignment changed — and sent last.
|
||||||
|
stale, _ := composeEach([]string{"anchor"}, allot, compose("before"))
|
||||||
|
// Composed after the change, sent first.
|
||||||
|
fresh, _ := composeEach([]string{"anchor"}, allot, compose("after"))
|
||||||
|
|
||||||
|
if stale[0].declared.Sequence != 1 || fresh[0].declared.Sequence != 2 {
|
||||||
|
t.Fatalf("the numbers do not follow the compositions: before=%d after=%d",
|
||||||
|
stale[0].declared.Sequence, fresh[0].declared.Sequence)
|
||||||
|
}
|
||||||
|
// Sent in the other order, the numbers do not change — so the machine that has applied the
|
||||||
|
// fresh one (2) refuses the stale one (1) when it arrives late.
|
||||||
|
if !(stale[0].declared.Sequence < fresh[0].declared.Sequence) {
|
||||||
|
t.Fatal("a declaration composed earlier must carry the lower number, however late it is sent")
|
||||||
|
}
|
||||||
|
if len(composed) != 2 || composed[0] != "before" {
|
||||||
|
t.Fatalf("compositions happened in an unexpected order: %v", composed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The number is taken before the first read of the composition, not after it: an allotter that
|
||||||
|
// fails leaves nothing composed for that machine, and the others are still composed.
|
||||||
|
func TestTheNumberIsTakenBeforeComposingAndItsFailureIsARefusal(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
allot := func(node string) (int64, error) {
|
||||||
|
if node == "anchor" {
|
||||||
|
return 0, context.DeadlineExceeded
|
||||||
|
}
|
||||||
|
return 7, nil
|
||||||
|
}
|
||||||
|
sending, refusals := composeEach([]string{"anchor", "laptop"}, allot, func(node string) (sendable, error) {
|
||||||
|
calls++
|
||||||
|
if node == "anchor" {
|
||||||
|
t.Fatal("anchor was composed although its number could not be taken")
|
||||||
|
}
|
||||||
|
return sendable{}, nil
|
||||||
|
})
|
||||||
|
if calls != 1 || len(sending) != 1 || sending[0].node != "laptop" || sending[0].declared.Sequence != 7 {
|
||||||
|
t.Fatalf("laptop should be composed with its number and anchor refused: %v / %v", sending, refusals)
|
||||||
|
}
|
||||||
|
if len(refusals) != 1 {
|
||||||
|
t.Fatalf("anchor's failed number should be a refusal naming it: %v", refusals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What was sent is written down even when the sender's context is already cancelled (issue 204): a
|
||||||
|
// controller replaced mid-send had told the machine and never recorded it, so status read "applied,
|
||||||
|
// current" over a machine that had just been sent something else.
|
||||||
|
func TestASendIsRecordedEvenWhenTheSenderIsBeingCancelled(t *testing.T) {
|
||||||
|
inv := inventory.ForTest(t)
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cancel() // the sender is going away: its context is cancelled between the send and the record
|
||||||
|
body := []byte(`{"declaration":1,"resources":[]}`)
|
||||||
|
digest, err := recordSent(ctx, inv, "anchor", body)
|
||||||
|
if err != nil {
|
||||||
|
// NodeByName on the cancelled context may itself refuse; the record must still be possible
|
||||||
|
// through the detached context, so look the node up again on a live one.
|
||||||
|
t.Fatalf("recording a send after cancellation failed: %v", err)
|
||||||
|
}
|
||||||
|
outstanding, err := inv.Outstanding(t.Context(), "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if outstanding != digest || digest != digestOf(body) {
|
||||||
|
t.Fatalf("the send was not recorded: outstanding %q, sent %q", outstanding, digest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,6 +147,40 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// **The same list, for something other than a person** (novox/hq ADR 0195): what each module
|
||||||
|
// is, where it runs, whether it is current, and what it says of itself.
|
||||||
|
if len(args) > 1 && args[1] == "--json" {
|
||||||
|
type listed struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Built string `json:"built,omitempty"`
|
||||||
|
Head string `json:"head,omitempty"`
|
||||||
|
Current bool `json:"current"`
|
||||||
|
Provided bool `json:"provided,omitempty"`
|
||||||
|
// Tools says whether the module answers tools anywhere it runs: a list of its own,
|
||||||
|
// a bundle the runtime serves, or a seat's verbs it claims (novox/hq ADR 0197) —
|
||||||
|
// what the console checks the bus's answers against.
|
||||||
|
Tools bool `json:"tools"`
|
||||||
|
On []string `json:"on"`
|
||||||
|
Provides []string `json:"provides,omitempty"`
|
||||||
|
Requires []string `json:"requires,omitempty"`
|
||||||
|
Claims []string `json:"claims,omitempty"`
|
||||||
|
Capabilities []string `json:"capabilities,omitempty"`
|
||||||
|
}
|
||||||
|
out := make([]listed, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
m := e.Manifest
|
||||||
|
l := listed{Module: m.Module, Version: m.Version, Built: e.Source.BuiltFrom, Head: e.Source.Head,
|
||||||
|
Current: e.Provided || e.Source.Repository == "" || e.Source.Current(), Provided: e.Provided,
|
||||||
|
On: append([]string{}, e.On...), Provides: m.Offers(), Requires: m.Requires,
|
||||||
|
Capabilities: m.Capabilities, Tools: declaresTools(m)}
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
l.Claims = append(l.Claims, c.At()+"/"+c.Name)
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return printJSON(out)
|
||||||
|
}
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
fmt.Println("this mesh knows about no modules yet")
|
fmt.Println("this mesh knows about no modules yet")
|
||||||
return nil
|
return nil
|
||||||
@@ -733,3 +767,23 @@ func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// declaresTools is whether a module answers tools wherever it runs (novox/hq ADR 0197): it names
|
||||||
|
// tools of its own, its build delivers a bundle the node's runtime serves, or it claims a seat
|
||||||
|
// whose verbs it serves. A module with none is never expected to announce anything.
|
||||||
|
func declaresTools(m catalogue.Manifest) bool {
|
||||||
|
if len(m.Tools) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, b := range m.Bundles {
|
||||||
|
if len(b.Loads) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if len(c.Serves) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -303,3 +304,28 @@ func TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves(t *t
|
|||||||
t.Fatalf("a machine that left the network is still a wildcard, or the one that stayed is not:\n%s", after)
|
t.Fatalf("a machine that left the network is still a wildcard, or the one that stayed is not:\n%s", after)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The roster is the machines and nothing else (novox/hq ADR 0191): each node's internal domain covers
|
||||||
|
// every route on it, and a node's public domains are public DNS's. A routed name in `.Names` was a
|
||||||
|
// private answer for a public name, handed by a resolver serving a LAN to a phone that could not use it.
|
||||||
|
func TestTheRosterNamesOnlyTheMachines(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
gens, err := generators(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, node := range []string{"anchor", "laptop"} {
|
||||||
|
plan, settings, err := planFor(ctx, open, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Reading)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(with.Names, with.Machines) {
|
||||||
|
t.Fatalf("%s's roster names more than the machines:\n names %v\n machines %v", node, with.Names, with.Machines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -44,6 +45,21 @@ func nodeCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// **The same list, for something other than a person** — the console's discovery reads it
|
||||||
|
// (novox/hq ADR 0195), and a reader that parses a printed column breaks when it is reworded.
|
||||||
|
if len(args) > 1 && args[1] == "--json" {
|
||||||
|
type listed struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Heard string `json:"heard"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
out := make([]listed, 0, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
out = append(out, listed{Name: n.Name, Heard: heardFrom(n), Mode: modeOf(n), ID: n.ID})
|
||||||
|
}
|
||||||
|
return printJSON(out)
|
||||||
|
}
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
// Said rather than printed as nothing: an empty list and a failed read must never
|
// Said rather than printed as nothing: an empty list and a failed read must never
|
||||||
// look the same, and this command answering "none" is only honest because getting
|
// look the same, and this command answering "none" is only honest because getting
|
||||||
@@ -500,3 +516,13 @@ func orNotReported(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printJSON prints a value as indented JSON, the shape every `--json` answers in.
|
||||||
|
func printJSON(v any) error {
|
||||||
|
body, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(body))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -645,23 +645,17 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// And every routed name → the node that serves it (novox/hq ADR 0066). Alongside the
|
// **The roster is the machines and nothing else** (novox/hq ADR 0191). Each node has one internal
|
||||||
// `<node>.internal` names above, so a container — or an internal ACME validator — resolves a
|
// domain, `<node>.internal`, and every route on it is a name under that domain (ADR 0151), which
|
||||||
// routed name to the proxy that serves it, mesh-wide. The mesh publishes the names it was told
|
// the resolver answers with one wildcard per machine — so no route needs a line of its own. A
|
||||||
// to serve and knows nothing about what they mean.
|
// node's public domains are the operator's and public DNS answers them; the mesh gives no private
|
||||||
// Kept apart from the machines, because a fact about the machines must not be handed the names
|
// answer for any of them. The roster once carried every routed name, public ones included, and a
|
||||||
// the mesh merely serves (novox/hq 04-ISSUES/111).
|
// resolver that also serves a LAN handed a phone a tunnel address for the mail server.
|
||||||
|
// `.Names` and `.Machines` stay two fields so a module's template keeps rendering (issue 111).
|
||||||
machines := make(map[string]string, len(names))
|
machines := make(map[string]string, len(names))
|
||||||
for name, at := range names {
|
for name, at := range names {
|
||||||
machines[name] = at
|
machines[name] = at
|
||||||
}
|
}
|
||||||
routes, err := routeNamesInTheMesh(ctx, open)
|
|
||||||
if err != nil {
|
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
|
||||||
}
|
|
||||||
for name, at := range routes {
|
|
||||||
names[name] = at
|
|
||||||
}
|
|
||||||
|
|
||||||
// **The bus is never public** (novox/hq ADR 0169). It was a foundation port — widened from the
|
// **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
|
// broker's own `from: mesh` to from-anywhere on the broker's host, so a machine could enrol
|
||||||
@@ -740,76 +734,6 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
}, record, nil
|
}, record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// routeNamesInTheMesh is every routed name and the address of the node that serves it (novox/hq
|
|
||||||
// ADR 0066).
|
|
||||||
//
|
|
||||||
// **Mesh-wide, so any container resolves any routed name to its proxy** — including an internal
|
|
||||||
// ACME validator, which cannot complete a challenge for a name it cannot reach. A routed name is
|
|
||||||
// composed on the consumer's node (from its label and that node's public domain) and served by the
|
|
||||||
// node answering the consumer's route requirement; this gathers both.
|
|
||||||
//
|
|
||||||
// It reads route names off resolutions rather than a table because there is no table: a route is a
|
|
||||||
// contribution, computed from what each node runs. Name-agnostic — a contribution counts as a
|
|
||||||
// routed name only because it carried a label the mesh composed, never because the mesh knows what
|
|
||||||
// "route" means. A node that does not resolve is skipped, so one machine's broken set does not cost
|
|
||||||
// the rest their names.
|
|
||||||
//
|
|
||||||
// **A node that could not be READ is a different matter and is raised.** Skipping one states, to
|
|
||||||
// every machine at once, that its names do not exist — and since the roster is part of every
|
|
||||||
// container's identity, that withdraws them and replaces every container (novox/hq 04-ISSUES/152,
|
|
||||||
// 151). So every failure here says which machine and which read, because the alternative is a
|
|
||||||
// mesh-wide refusal with nothing named in it.
|
|
||||||
func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every machine's resolution first, then the names across them at once: which node serves a
|
|
||||||
// name is a question about the graph — the consumer on one machine, the provider on another —
|
|
||||||
// and answered wrongly by looking at one contribution at a time (novox/hq issue 178).
|
|
||||||
plans := map[string]catalogue.Resolution{}
|
|
||||||
settings := map[string]catalogue.SettingsBy{}
|
|
||||||
for _, n := range nodes {
|
|
||||||
plan, layers, err := planFor(ctx, open, n.Name)
|
|
||||||
switch {
|
|
||||||
case unresolvable(err):
|
|
||||||
// Their set does not compose, so they serve no names. Passed over, so one machine's
|
|
||||||
// broken set does not cost the rest theirs.
|
|
||||||
continue
|
|
||||||
case err != nil:
|
|
||||||
// The mesh could not be asked. Returning the roster without this machine's names would
|
|
||||||
// state that they do not exist — to every machine, and indistinguishably from the
|
|
||||||
// operator having withdrawn them (novox/hq 04-ISSUES/152).
|
|
||||||
return nil, fmt.Errorf("the names %s serves cannot be read: %w", n.Name, err)
|
|
||||||
}
|
|
||||||
plans[n.Name], settings[n.Name] = plan, layers
|
|
||||||
}
|
|
||||||
served, err := catalogue.NamesServed(plans, settings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := map[string]string{}
|
|
||||||
for name, node := range served {
|
|
||||||
if at := address[node]; at != "" {
|
|
||||||
out[name] = at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// certificateFor is what the mesh certifies about one machine's internal name.
|
// 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
|
// It reaches across two contexts and reads neither one's store from the other: `inventory` knows
|
||||||
|
|||||||
+71
-37
@@ -151,6 +151,12 @@ func serve(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer stopServing()
|
defer stopServing()
|
||||||
|
// And says so on the bus (novox/hq ADR 0197): what it serves, as the NATS services protocol asks.
|
||||||
|
stopAnnouncing, err := bus.Announce(seatAnnouncement(handlers), log.New(os.Stdout, "", log.LstdFlags))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stopAnnouncing()
|
||||||
|
|
||||||
return server.Serve(ctx)
|
return server.Serve(ctx)
|
||||||
}
|
}
|
||||||
@@ -205,6 +211,12 @@ func declare(ctx context.Context, args []string) error {
|
|||||||
if err := link.Declare(ctx, server.Bus(), ident, node, raw, 15*time.Second); err != nil {
|
if err := link.Declare(ctx, server.Bus(), ident, node, raw, 15*time.Second); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Written down like every other send (novox/hq issue 204): a declaration a person sent by hand
|
||||||
|
// is still what the machine was last told, and status must not read it as current for the one
|
||||||
|
// the mesh would compose.
|
||||||
|
if _, err := recordSent(ctx, inv, node, raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
fmt.Printf("sent %s a signed declaration (%d bytes)\n", node, len(raw))
|
fmt.Printf("sent %s a signed declaration (%d bytes)\n", node, len(raw))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -348,7 +360,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
sending, refusals := composeEach(asked, func(node string) (sendable, error) {
|
sending, refusals := composeEach(asked, allotting(held, inv), func(node string) (sendable, error) {
|
||||||
plan, settings, err := planFor(held, open, node)
|
plan, settings, err := planFor(held, open, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
@@ -370,12 +382,8 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
sentDigest := map[string]string{}
|
sentDigest := map[string]string{}
|
||||||
defer release()
|
defer release()
|
||||||
for _, s := range sending {
|
for _, s := range sending {
|
||||||
// Numbered under the hold, one higher than the last, before the body exists — the number is
|
// The number is inside the signed bytes, so a replayed older declaration cannot borrow a
|
||||||
// inside the signed bytes, so a replayed older declaration cannot borrow a newer one's
|
// newer one's (novox/hq 04-ISSUES/107); it was taken when the composition began (issue 204).
|
||||||
// (novox/hq 04-ISSUES/107).
|
|
||||||
if err := number(ctx, inv, &s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
body, err := s.declared.Body()
|
body, err := s.declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -385,14 +393,10 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
// After it is away, not before. A digest recorded for something that failed to send would
|
// After it is away, not before. A digest recorded for something that failed to send would
|
||||||
// make the machine look current for a declaration it never received.
|
// make the machine look current for a declaration it never received.
|
||||||
record, err := inv.NodeByName(ctx, s.node)
|
digest, err := recordSent(ctx, inv, s.node, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
digest := digestOf(body)
|
|
||||||
if err := inv.RecordSent(ctx, record.ID, digest); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sentDigest[s.node] = digest
|
sentDigest[s.node] = digest
|
||||||
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||||
}
|
}
|
||||||
@@ -476,11 +480,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
15*time.Second); err != nil {
|
15*time.Second); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
record, err := inv.NodeByName(ctx, s.node)
|
if _, err := recordSent(ctx, inv, s.node, body); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||||
@@ -569,17 +569,31 @@ type readyNode struct {
|
|||||||
//
|
//
|
||||||
// The all-or-nothing rule is kept where it means something — sendTo, which rotates a credential
|
// The all-or-nothing rule is kept where it means something — sendTo, which rotates a credential
|
||||||
// across two machines that must agree — and dropped here, where it never did.
|
// across two machines that must agree — and dropped here, where it never did.
|
||||||
func composeEach(names []string,
|
func composeEach(names []string, allot func(node string) (int64, error),
|
||||||
compose func(node string) (sendable, error)) ([]readyNode, []string) {
|
compose func(node string) (sendable, error)) ([]readyNode, []string) {
|
||||||
|
|
||||||
var sending []readyNode
|
var sending []readyNode
|
||||||
var refusals []string
|
var refusals []string
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
|
// **Numbered before it is composed, not before it is sent** (novox/hq issue 204). The
|
||||||
|
// number says where this declaration stands against every other the mesh composed for the
|
||||||
|
// machine, and the host refuses one lower than the last it applied. Taken at send time, as
|
||||||
|
// it was, a declaration composed a minute ago — before an assignment changed — went out with
|
||||||
|
// a number higher than one composed after the change and sent before it, and the machine
|
||||||
|
// took the older content as the newer word: on 2026-10-02 a runtime assigned and applied on
|
||||||
|
// two machines was undone two seconds later by exactly that. Taken here, before the first
|
||||||
|
// read, what was composed earlier is numbered lower whatever order the sends happen in.
|
||||||
|
seq, err := allot(name)
|
||||||
|
if err != nil {
|
||||||
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
declared, err := compose(name)
|
declared, err := compose(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
declared.Sequence = seq
|
||||||
if len(declared.Resources) == 0 {
|
if len(declared.Resources) == 0 {
|
||||||
// Sent, not skipped (novox/hq issue 127). A node whose declaration composes to
|
// Sent, not skipped (novox/hq issue 127). A node whose declaration composes to
|
||||||
// nothing may have HELD something before — the broker opening a placement gave it,
|
// nothing may have HELD something before — the broker opening a placement gave it,
|
||||||
@@ -607,13 +621,10 @@ func sendRound(ctx context.Context, open *stores, names []string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
sending, refused := composeEach(names, func(node string) (sendable, error) {
|
sending, refused := composeEach(names, allotting(held, open.inventory), func(node string) (sendable, error) {
|
||||||
return compose(held, node)
|
return compose(held, node)
|
||||||
})
|
})
|
||||||
for _, s := range sending {
|
for _, s := range sending {
|
||||||
if err := number(ctx, open.inventory, &s); err != nil {
|
|
||||||
return refused, err
|
|
||||||
}
|
|
||||||
body, err := s.declared.Body()
|
body, err := s.declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return refused, err
|
return refused, err
|
||||||
@@ -670,6 +681,12 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
var sending []readyNode
|
var sending []readyNode
|
||||||
var refusals []string
|
var refusals []string
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
|
// Numbered before composing, for the reason composeEach gives (novox/hq issue 204).
|
||||||
|
seq, err := allot(ctx, inv, name)
|
||||||
|
if err != nil {
|
||||||
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
plan, settings, err := planFor(ctx, open, name)
|
plan, settings, err := planFor(ctx, open, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
@@ -681,6 +698,7 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
declared.Sequence = seq
|
||||||
reportLeftOut(name, declared)
|
reportLeftOut(name, declared)
|
||||||
sending = append(sending, readyNode{name, declared})
|
sending = append(sending, readyNode{name, declared})
|
||||||
}
|
}
|
||||||
@@ -696,9 +714,6 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
for _, s := range sending {
|
for _, s := range sending {
|
||||||
if err := number(ctx, inv, &s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
body, err := s.declared.Body()
|
body, err := s.declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -706,11 +721,7 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
if err := link.Declare(ctx, server.Bus(), ident, s.node, body, 15*time.Second); err != nil {
|
if err := link.Declare(ctx, server.Bus(), ident, s.node, body, 15*time.Second); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
record, err := inv.NodeByName(ctx, s.node)
|
if _, err := recordSent(ctx, inv, s.node, body); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||||
@@ -951,15 +962,38 @@ func seatHolders(ctx context.Context, inv *inventory.Inventory) (map[string]brok
|
|||||||
}
|
}
|
||||||
|
|
||||||
// number gives one send the next sequence for its node (novox/hq 04-ISSUES/107).
|
// number gives one send the next sequence for its node (novox/hq 04-ISSUES/107).
|
||||||
func number(ctx context.Context, inv *inventory.Inventory, s *readyNode) error {
|
// allotting is allot over one inventory, in the shape composeEach takes.
|
||||||
record, err := inv.NodeByName(ctx, s.node)
|
func allotting(ctx context.Context, inv *inventory.Inventory) func(node string) (int64, error) {
|
||||||
|
return func(node string) (int64, error) { return allot(ctx, inv, node) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// allot takes the next sequence for a machine — the number its next declaration carries.
|
||||||
|
func allot(ctx context.Context, inv *inventory.Inventory, node string) (int64, error) {
|
||||||
|
record, err := inv.NodeByName(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
seq, err := inv.NextSequence(ctx, record.ID)
|
return inv.NextSequence(ctx, record.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSent writes down what a machine was just sent, and returns the digest.
|
||||||
|
//
|
||||||
|
// **On a context that outlives the caller's** (novox/hq issue 204). The record is written after the
|
||||||
|
// declaration is away, so a send that failed is never recorded as current — and a controller being
|
||||||
|
// replaced mid-send had its context cancelled between the two, so the machine was told and the mesh
|
||||||
|
// never wrote it down: status read "applied, current" over a machine that had just been sent
|
||||||
|
// something else. What was sent was sent; the record of it must not depend on the sender living
|
||||||
|
// another second. Bounded, so a store that is away does not hold a dying process open for ever.
|
||||||
|
func recordSent(ctx context.Context, inv *inventory.Inventory, node string, body []byte) (string, error) {
|
||||||
|
kept, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
record, err := inv.NodeByName(kept, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
s.declared.Sequence = seq
|
digest := digestOf(body)
|
||||||
return nil
|
if err := inv.RecordSent(kept, record.ID, digest); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return digest, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
// the wrong machine no longer refuses the whole node), applied one level up.
|
// the wrong machine no longer refuses the whole node), applied one level up.
|
||||||
func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
|
func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
|
||||||
sending, refusals := composeEach(
|
sending, refusals := composeEach(
|
||||||
[]string{"anchor", "home-server", "laptop"},
|
[]string{"anchor", "home-server", "laptop"}, numbered(),
|
||||||
func(node string) (sendable, error) {
|
func(node string) (sendable, error) {
|
||||||
if node == "anchor" {
|
if node == "anchor" {
|
||||||
return sendable{}, errors.New(`nothing provides "acme-ca", wanted by route-proxy`)
|
return sendable{}, errors.New(`nothing provides "acme-ca", wanted by route-proxy`)
|
||||||
@@ -43,7 +43,7 @@ func TestOneUnresolvableNodeStillLetsTheRestBeSent(t *testing.T) {
|
|||||||
// (novox/hq issue 127): it may have held something before, and only sending the empty
|
// (novox/hq issue 127): it may have held something before, and only sending the empty
|
||||||
// declaration tells it to drop what the mesh owned. It is never a refusal.
|
// declaration tells it to drop what the mesh owned. It is never a refusal.
|
||||||
func TestAnEmptyDeclarationIsSentSoTheNodeDropsWhatItHeld(t *testing.T) {
|
func TestAnEmptyDeclarationIsSentSoTheNodeDropsWhatItHeld(t *testing.T) {
|
||||||
sending, refusals := composeEach([]string{"spare"},
|
sending, refusals := composeEach([]string{"spare"}, numbered(),
|
||||||
func(string) (sendable, error) { return sendable{}, nil })
|
func(string) (sendable, error) { return sendable{}, nil })
|
||||||
if len(sending) != 1 || len(refusals) != 0 {
|
if len(sending) != 1 || len(refusals) != 0 {
|
||||||
t.Errorf("an empty declaration must be sent, not skipped or refused: %v / %v", sending, refusals)
|
t.Errorf("an empty declaration must be sent, not skipped or refused: %v / %v", sending, refusals)
|
||||||
@@ -74,3 +74,9 @@ func TestASkippedMachineIsStillAnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// numbered is an allotter for tests: one higher per call, as the inventory's is per machine.
|
||||||
|
func numbered() func(string) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
return func(string) (int64, error) { n++; return n, nil }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||||
fmt.Printf(" tier %d: ", p.Tier)
|
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.State = "failed"
|
||||||
state.Why = err.Error()
|
state.Why = err.Error()
|
||||||
p.State = inventory.PlanFailed
|
p.State = inventory.PlanFailed
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A node's own set failing to compose, and the mesh being unable to answer at all, are different
|
// A node's own set failing to compose, and the mesh being unable to answer at all, are different
|
||||||
// things, and only the first may be passed over when something is gathered across every machine
|
// things, and only the first may be passed over when something is gathered across every machine
|
||||||
// (novox/hq 04-ISSUES/152). These pin that distinction where the three gatherers rely on it.
|
// (novox/hq 04-ISSUES/152). These pin that distinction where the gatherers rely on it.
|
||||||
|
|
||||||
func TestASetThatDoesNotComposeIsMarkedAsTheNodesOwnProblem(t *testing.T) {
|
func TestASetThatDoesNotComposeIsMarkedAsTheNodesOwnProblem(t *testing.T) {
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
@@ -45,55 +44,3 @@ func TestAStoreThatCannotBeReadIsNotANodeThatDoesNotCompose(t *testing.T) {
|
|||||||
t.Fatalf("a question the mesh could not answer was read as a node that runs nothing: %v", err)
|
t.Fatalf("a question the mesh could not answer was read as a node that runs nothing: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOneIncoherentNodeDoesNotCostTheRestTheirNames(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
one, two := rivals()
|
|
||||||
register(t, open, one)
|
|
||||||
register(t, open, two)
|
|
||||||
for _, m := range []string{one.Module, two.Module} {
|
|
||||||
if _, err := open.inventory.Assign(t.Context(), "laptop", m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// laptop cannot compose. That is laptop's problem and nobody else's: the roster is still
|
|
||||||
// answerable, and anchor keeps whatever it serves.
|
|
||||||
if _, err := routeNamesInTheMesh(t.Context(), open); err != nil {
|
|
||||||
t.Fatalf("one node's broken set cost the whole mesh its roster: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestARosterIsNeverReturnedWithNamesItCouldNotRead(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
|
|
||||||
stopped, cancel := context.WithCancel(t.Context())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
names, err := routeNamesInTheMesh(stopped, open)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("a roster was composed from a store that could not be read: %v", names)
|
|
||||||
}
|
|
||||||
// The failure must be raised, not turned into an absence. A roster missing a machine's names
|
|
||||||
// is indistinguishable, on every machine that receives it, from the operator withdrawing them —
|
|
||||||
// and because the roster is part of every container's identity, it replaces all of them.
|
|
||||||
if names != nil {
|
|
||||||
t.Fatalf("a partial roster was returned beside the error: %v", names)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kept so the reason survives the next person reading it: the message the gatherer raises must say
|
|
||||||
// which machine could not be read, or the operator is left with a mesh-wide failure and no name.
|
|
||||||
func TestTheRaisedFailureNamesTheMachineItCouldNotRead(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
stopped, cancel := context.WithCancel(t.Context())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
_, err := routeNamesInTheMesh(stopped, open)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("no failure was raised")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "cannot be read") {
|
|
||||||
t.Fatalf("the failure does not say the mesh could not be read: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/nats-io/nats.go/micro"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -66,14 +68,14 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
case "status":
|
case "status":
|
||||||
return []string{"status", "--json"}, nil
|
return []string{"status", "--json"}, nil
|
||||||
case "nodes":
|
case "nodes":
|
||||||
return []string{"node", "list"}, nil
|
return []string{"node", "list", "--json"}, nil
|
||||||
case "node":
|
case "node":
|
||||||
if err := need("node"); err != nil {
|
if err := need("node"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return []string{"node", "show", str("node")}, nil
|
return []string{"node", "show", str("node")}, nil
|
||||||
case "modules":
|
case "modules":
|
||||||
return []string{"module", "list"}, nil
|
return []string{"module", "list", "--json"}, nil
|
||||||
case "seats":
|
case "seats":
|
||||||
return []string{"seats", "--json"}, nil
|
return []string{"seats", "--json"}, nil
|
||||||
case "builds":
|
case "builds":
|
||||||
@@ -379,3 +381,43 @@ func splitCommandLine(line string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
return words, nil
|
return words, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seatAnnouncement is what the controller says it serves on the bus (novox/hq ADR 0197): the
|
||||||
|
// mesh-controller seat, one endpoint per verb it answers, each with the seat's own description and
|
||||||
|
// argument schema — the same facts `tools` answers from the records, as NATS's services format.
|
||||||
|
func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
|
||||||
|
about := map[string]catalogue.Verb{}
|
||||||
|
for _, s := range catalogue.SeatsWithAProtocol() {
|
||||||
|
if s.Name == catalogue.ControllerSeatName {
|
||||||
|
for _, v := range s.Serves {
|
||||||
|
about[v.Name] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verbs := make([]string, 0, len(handlers))
|
||||||
|
for verb := range handlers {
|
||||||
|
verbs = append(verbs, verb)
|
||||||
|
}
|
||||||
|
sort.Strings(verbs)
|
||||||
|
var endpoints []micro.EndpointInfo
|
||||||
|
for _, verb := range verbs {
|
||||||
|
schema, _ := json.Marshal(about[verb].Input)
|
||||||
|
endpoints = append(endpoints, micro.EndpointInfo{
|
||||||
|
Name: verb,
|
||||||
|
Subject: link.SeatToolSubject(catalogue.ControllerSeatName, verb),
|
||||||
|
QueueGroup: "seat." + catalogue.ControllerSeatName,
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"description": about[verb].Description, "schema": string(schema),
|
||||||
|
"seat": catalogue.ControllerSeatName, "scope": "mesh",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return micro.Info{
|
||||||
|
ServiceIdentity: micro.ServiceIdentity{
|
||||||
|
Name: catalogue.ControllerSeatName, ID: "controller", Version: "1.0.0",
|
||||||
|
Metadata: map[string]string{"seat": catalogue.ControllerSeatName, "scope": "mesh"},
|
||||||
|
},
|
||||||
|
Description: "the mesh's own verbs, answered by the holder of the mesh-controller seat",
|
||||||
|
Endpoints: endpoints,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -249,3 +251,44 @@ func TestARowAheadOfThisBuildIsServedAnyway(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0195: the console's discovery reads the machines and the modules; they answer as JSON,
|
||||||
|
// as status and seats do, so nothing parses a printed column.
|
||||||
|
func TestTheNodesAndModulesVerbsAnswerAsJSON(t *testing.T) {
|
||||||
|
for verb, want := range map[string]string{"nodes": "[node list --json]", "modules": "[module list --json]"} {
|
||||||
|
argv, err := argvFor(verb, map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(argv) != want {
|
||||||
|
t.Errorf("%s runs %v, want %s", verb, argv, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0197: the controller announces exactly the verbs it serves, each on the subject and
|
||||||
|
// queue it serves it on, with the seat's own description and schema, in NATS's services format.
|
||||||
|
func TestTheControllerAnnouncesTheVerbsItServes(t *testing.T) {
|
||||||
|
handlers, _, err := seatToolHandlers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info := seatAnnouncement(handlers)
|
||||||
|
if info.Name != catalogue.ControllerSeatName || info.ID == "" || info.Version == "" {
|
||||||
|
t.Fatalf("the service is not named for the seat: %+v", info.ServiceIdentity)
|
||||||
|
}
|
||||||
|
if len(info.Endpoints) != len(handlers) {
|
||||||
|
t.Fatalf("%d endpoints announced for %d verbs served", len(info.Endpoints), len(handlers))
|
||||||
|
}
|
||||||
|
for _, e := range info.Endpoints {
|
||||||
|
if _, served := handlers[e.Name]; !served {
|
||||||
|
t.Errorf("%s is announced and not served", e.Name)
|
||||||
|
}
|
||||||
|
if e.Subject != link.SeatToolSubject(catalogue.ControllerSeatName, e.Name) || e.QueueGroup != "seat."+catalogue.ControllerSeatName {
|
||||||
|
t.Errorf("%s is announced on %s/%s, not where it is served", e.Name, e.Subject, e.QueueGroup)
|
||||||
|
}
|
||||||
|
if e.Metadata["description"] == "" || e.Metadata["schema"] == "" || e.Metadata["scope"] != "mesh" {
|
||||||
|
t.Errorf("%s is announced without its description, schema or scope: %v", e.Name, e.Metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -283,6 +284,14 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
|
||||||
packaging = nil
|
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)
|
touched := whatTheMergeTouched(from, entries, m)
|
||||||
for _, e := range touched {
|
for _, e := range touched {
|
||||||
if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil {
|
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) {
|
if !sameRepository(s.Repository, m) {
|
||||||
return false
|
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
|
// sameRepository is whether a recorded repository is the one a merge names, in either spelling it
|
||||||
|
|||||||
@@ -235,14 +235,22 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
|
|||||||
return fmt.Errorf("asking about stream %s to re-make consumer %s: %w", c.Stream, c.Name, err)
|
return fmt.Errorf("asking about stream %s to re-make consumer %s: %w", c.Stream, c.Name, err)
|
||||||
}
|
}
|
||||||
if info.Config.Retention != nats.WorkQueuePolicy {
|
if info.Config.Retention != nats.WorkQueuePolicy {
|
||||||
j.note("consumer %s on %s is %s and should be %s; not re-made, because %s keeps its history "+
|
// **A stream that keeps its history is re-made from now on, never from the start.**
|
||||||
"and a re-made consumer replays what this one acknowledged (novox/hq issue 156). Re-make it by hand",
|
// Left for a hand, the hand re-makes it with the server's default — everything the
|
||||||
c.Name, c.Stream, shape(havePush), shape(wantPush), c.Stream)
|
// stream holds — which on 2026-10-03 replayed every build ask since 1 October and
|
||||||
return nil
|
// re-registered nine modules from the past (novox/hq issue 207). What this consumer
|
||||||
|
// had not yet acknowledged is lost with it, and said: on a history stream that is
|
||||||
|
// the smaller cost, and the asks in flight are visible to whoever asked.
|
||||||
|
j.note("consumer %s on %s changes from %s to %s delivery on a stream that keeps its history: "+
|
||||||
|
"re-made to deliver from now on, so nothing this one acknowledged comes back (novox/hq issue "+
|
||||||
|
"207); %d ask(s) it had not acknowledged are not carried over and must be asked again",
|
||||||
|
c.Name, c.Stream, shape(havePush), shape(wantPush), have.NumPending+uint64(have.NumAckPending))
|
||||||
|
want.DeliverPolicy = nats.DeliverNewPolicy
|
||||||
|
} else {
|
||||||
|
j.note("consumer %s on %s changes from %s to %s delivery: re-made where it left off, nothing "+
|
||||||
|
"acknowledged comes back and nothing pending is lost (novox/hq issue 206); a holder bound to "+
|
||||||
|
"the old shape binds again", c.Name, c.Stream, shape(havePush), shape(wantPush))
|
||||||
}
|
}
|
||||||
j.note("consumer %s on %s changes from %s to %s delivery: re-made where it left off, nothing "+
|
|
||||||
"acknowledged comes back and nothing pending is lost (novox/hq issue 206); a holder bound to "+
|
|
||||||
"the old shape binds again", c.Name, c.Stream, shape(havePush), shape(wantPush))
|
|
||||||
if err := j.js.DeleteConsumer(c.Stream, c.Name); err != nil {
|
if err := j.js.DeleteConsumer(c.Stream, c.Name); err != nil {
|
||||||
return fmt.Errorf("re-making consumer %s on %s as %s: %w", c.Name, c.Stream, shape(wantPush), err)
|
return fmt.Errorf("re-making consumer %s on %s as %s: %w", c.Name, c.Stream, shape(wantPush), err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// which this package mirrors rather than reads, and a verb the seat does not declare is a
|
// which this package mirrors rather than reads, and a verb the seat does not declare is a
|
||||||
// subject nothing publishes.
|
// subject nothing publishes.
|
||||||
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
|
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
|
||||||
|
// And says so (novox/hq ADR 0197): it answers discovery for the seat it serves.
|
||||||
|
sub = append(sub, announcing(ControllerSeat)...)
|
||||||
|
|
||||||
// The two events it reacts to, and its ack subject on the stream they arrive from
|
// The two events it reacts to, and its ack subject on the stream they arrive from
|
||||||
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
|
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
|
||||||
@@ -271,6 +273,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
return Permissions{}, err
|
return Permissions{}, err
|
||||||
}
|
}
|
||||||
pub = append(pub, invoked...)
|
pub = append(pub, invoked...)
|
||||||
|
// And may ask what answers (novox/hq ADR 0197): a question every service answers about
|
||||||
|
// itself, its replies to the asker's own inbox.
|
||||||
|
pub = append(pub, discovering()...)
|
||||||
|
|
||||||
case KindEnrolment:
|
case KindEnrolment:
|
||||||
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
||||||
@@ -327,6 +332,15 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// away — no other principal may subscribe this namespace, and a caller's authority is
|
// away — no other principal may subscribe this namespace, and a caller's authority is
|
||||||
// still granted per tool, by name, on the publish side.
|
// still granted per tool, by name, on the publish side.
|
||||||
sub = append(sub, own+".tool.>")
|
sub = append(sub, own+".tool.>")
|
||||||
|
// It says what it serves (novox/hq ADR 0197): discovery for its own name and every seat it
|
||||||
|
// holds a verb of, answered by the runtime that serves them.
|
||||||
|
announced := []string{p.Module}
|
||||||
|
for _, s := range p.Holds {
|
||||||
|
if len(s.Serves) > 0 {
|
||||||
|
announced = append(announced, s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sub = append(sub, announcing(announced...)...)
|
||||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
|
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
|
||||||
// directly from the stream and followed live. Nothing else's.
|
// directly from the stream and followed live. Nothing else's.
|
||||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
||||||
@@ -413,11 +427,16 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// module's own principal has, for the same reason: the tools a module serves are what its
|
// module's own principal has, for the same reason: the tools a module serves are what its
|
||||||
// code answers, and a list here would be a second copy of it. Each held seat's verbs on
|
// code answers, and a list here would be a second copy of it. Each held seat's verbs on
|
||||||
// this node, as the holder's own principal would be granted them.
|
// this node, as the holder's own principal would be granted them.
|
||||||
|
var serves []string
|
||||||
for _, d := range p.Carries {
|
for _, d := range p.Carries {
|
||||||
if !safeSubject.MatchString(d.Module) {
|
if !safeSubject.MatchString(d.Module) {
|
||||||
return Permissions{}, fmt.Errorf(
|
return Permissions{}, fmt.Errorf(
|
||||||
"%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", d.Module)
|
"%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", d.Module)
|
||||||
}
|
}
|
||||||
|
serves = append(serves, d.Module)
|
||||||
|
for _, s := range d.Holds {
|
||||||
|
serves = append(serves, s.Name)
|
||||||
|
}
|
||||||
own := "mesh.mod." + d.Module
|
own := "mesh.mod." + d.Module
|
||||||
sub = append(sub, own+".tool.>")
|
sub = append(sub, own+".tool.>")
|
||||||
// A tool that emits an event is the module's code and emits under the module's name
|
// A tool that emits an event is the module's code and emits under the module's name
|
||||||
@@ -444,6 +463,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
return Permissions{}, err
|
return Permissions{}, err
|
||||||
}
|
}
|
||||||
pub = append(pub, invoked...)
|
pub = append(pub, invoked...)
|
||||||
|
// It says what it serves and may ask what answers (novox/hq ADR 0197): the runtime answers
|
||||||
|
// discovery for each module and seat it carries, and the console it is asks the bus.
|
||||||
|
sub = append(sub, announcing(serves...)...)
|
||||||
|
pub = append(pub, discovering()...)
|
||||||
// Nothing about consumers: it consumes nothing. A module's reactions to events are its
|
// 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.
|
// own long-lived process, which ADR 0175 leaves where it is; what moves here is tools.
|
||||||
sub = unique(sub)
|
sub = unique(sub)
|
||||||
@@ -765,3 +788,23 @@ func invokedSubjects(invokes []string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// announcing is what a principal that serves tools subscribes to answer the NATS services
|
||||||
|
// protocol's discovery (novox/hq ADR 0197): the questions asked of every service, and those asked of
|
||||||
|
// each name it serves — its own and no other's, so it cannot answer for a service it is not.
|
||||||
|
func announcing(names ...string) []string {
|
||||||
|
out := []string{"$SRV.PING", "$SRV.INFO"}
|
||||||
|
for _, n := range names {
|
||||||
|
if !safeSubject.MatchString(n) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, "$SRV.PING."+n, "$SRV.PING."+n+".>", "$SRV.INFO."+n, "$SRV.INFO."+n+".>")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// discovering is what a principal publishes to ask what answers (novox/hq ADR 0197): the services
|
||||||
|
// protocol's discovery requests, whose replies come to its own inbox.
|
||||||
|
func discovering() []string {
|
||||||
|
return []string{"$SRV.PING", "$SRV.PING.>", "$SRV.INFO", "$SRV.INFO.>"}
|
||||||
|
}
|
||||||
|
|||||||
@@ -233,7 +233,9 @@ func TestAPersonReachesNothingButTools(t *testing.T) {
|
|||||||
perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo",
|
perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo",
|
||||||
Invokes: []string{"*"}, PasswordHash: "x"})
|
Invokes: []string{"*"}, PasswordHash: "x"})
|
||||||
for _, p := range perms.Publish {
|
for _, p := range perms.Publish {
|
||||||
if !strings.Contains(p, ".tool.") {
|
// A tool call, or asking what answers (novox/hq ADR 0197) — a question every service
|
||||||
|
// answers about itself, which claims nothing and controls nothing.
|
||||||
|
if !strings.Contains(p, ".tool.") && !strings.HasPrefix(p, "$SRV.") {
|
||||||
t.Errorf("a person may publish %q, which is not a tool call", p)
|
t.Errorf("a person may publish %q, which is not a tool call", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,13 +425,17 @@ func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) {
|
|||||||
if _, needed := ConsumerFor(p); needed {
|
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, which consumes nothing")
|
||||||
}
|
}
|
||||||
// Each subject once: the file is read as the mesh's authority model.
|
// Each subject once in each list: the file is read as the mesh's authority model. One subject may
|
||||||
seen := map[string]bool{}
|
// stand in both — the runtime answers discovery on `$SRV.INFO` and, as the console, asks it
|
||||||
for _, s := range append(append([]string{}, perms.Subscribe...), perms.Publish...) {
|
// (novox/hq ADR 0197) — because subscribing and publishing are two different grants.
|
||||||
if seen[s] {
|
for _, list := range [][]string{perms.Subscribe, perms.Publish} {
|
||||||
t.Errorf("%s is granted twice", s)
|
seen := map[string]bool{}
|
||||||
|
for _, s := range list {
|
||||||
|
if seen[s] {
|
||||||
|
t.Errorf("%s is granted twice", s)
|
||||||
|
}
|
||||||
|
seen[s] = true
|
||||||
}
|
}
|
||||||
seen[s] = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -25,7 +25,7 @@ accounts {
|
|||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused", "mesh.seat.node-build-agent.accept.>"] }
|
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused", "mesh.seat.node-build-agent.accept.>"] }
|
||||||
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
|
subscribe: { allow: ["$JS.API.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||||
@@ -38,17 +38,17 @@ accounts {
|
|||||||
} }
|
} }
|
||||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||||
subscribe: { allow: ["_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] }
|
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] }
|
||||||
subscribe: { allow: ["_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
||||||
subscribe: { allow: ["_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
|
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -108,9 +108,10 @@ func TestAWorkerOfTheWrongTypeIsRemadeOnAWorkQueueAndAPullThenBinds(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// On a stream that keeps its history, a worker of the wrong type is said and left: re-making it would
|
// On a stream that keeps its history, a worker of the wrong type is re-made to deliver from now on:
|
||||||
// replay what it acknowledged (novox/hq issue 156), and that is a person's call.
|
// re-making it from the start would replay what it acknowledged (novox/hq issue 156), and leaving it
|
||||||
func TestAWorkerOfTheWrongTypeOnAHistoryStreamIsLeftAndSaid(t *testing.T) {
|
// for a hand re-made it exactly that way on 2026-10-03 (issue 207).
|
||||||
|
func TestAWorkerOfTheWrongTypeOnAHistoryStreamIsRemadeFromNowOn(t *testing.T) {
|
||||||
url := os.Getenv("MESH_TEST_NATS")
|
url := os.Getenv("MESH_TEST_NATS")
|
||||||
if url == "" {
|
if url == "" {
|
||||||
t.Skip("MESH_TEST_NATS unset")
|
t.Skip("MESH_TEST_NATS unset")
|
||||||
@@ -132,6 +133,12 @@ func TestAWorkerOfTheWrongTypeOnAHistoryStreamIsLeftAndSaid(t *testing.T) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// History the old consumer would have acknowledged long ago, and must not come back.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := js.js.Publish("mesh.t.event.old", []byte("old")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := js.EnsureConsumer(Consumer{Name: worker, Stream: stream, Filters: []string{filter}, AckWaitSeconds: 60}); err != nil {
|
if err := js.EnsureConsumer(Consumer{Name: worker, Stream: stream, Filters: []string{filter}, AckWaitSeconds: 60}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -139,7 +146,22 @@ func TestAWorkerOfTheWrongTypeOnAHistoryStreamIsLeftAndSaid(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if have.Config.DeliverSubject == "" {
|
if have.Config.DeliverSubject != "" {
|
||||||
t.Fatal("a history stream's consumer was re-made, which replays what it acknowledged")
|
t.Fatal("a history stream's consumer of the wrong type was left as it was")
|
||||||
|
}
|
||||||
|
if have.Config.DeliverPolicy != nats.DeliverNewPolicy || have.NumPending != 0 {
|
||||||
|
t.Fatalf("re-made consumer delivers %v with %d pending; it must deliver from now on with nothing of the past", have.Config.DeliverPolicy, have.NumPending)
|
||||||
|
}
|
||||||
|
// And what arrives from now on is delivered.
|
||||||
|
if _, err := js.js.Publish("mesh.t.event.new", []byte("new")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sub, err := js.js.PullSubscribe(filter, worker, nats.Bind(stream, worker))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := sub.Fetch(1, nats.MaxWait(3*time.Second))
|
||||||
|
if err != nil || len(got) != 1 || string(got[0].Data) != "new" {
|
||||||
|
t.Fatalf("the re-made consumer delivered %v, %v; want the one new message", got, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -587,6 +587,12 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: compiling %s failed: %w", module, a.Name, err)
|
||||||
}
|
}
|
||||||
|
// **Every entrypoint the runtime may serve is executable** (novox/hq ADR 0193). The runtime
|
||||||
|
// knows no language; for one that runs through an interpreter the build writes the launcher.
|
||||||
|
launchers, err := writeLaunchers(compiled, chain, a)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Built{}, fmt.Errorf("%s: writing %s's launchers failed: %w", module, a.Name, err)
|
||||||
|
}
|
||||||
say("bundle", "compiled, packing")
|
say("bundle", "compiled, packing")
|
||||||
body, err := pack(compiled)
|
body, err := pack(compiled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -598,7 +604,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, err
|
return catalogue.Built{}, err
|
||||||
}
|
}
|
||||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest}, nil
|
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: where, Digest: digest, Launchers: launchers}, nil
|
||||||
|
|
||||||
case catalogue.ArtifactPackage:
|
case catalogue.ArtifactPackage:
|
||||||
// Built and published on a public base, to the mesh's package registry, by version
|
// Built and published on a public base, to the mesh's package registry, by version
|
||||||
@@ -1165,6 +1171,9 @@ func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext {
|
|||||||
// binaryName is what a compiled bundle's executable is called: what the artifact says, or the name of
|
// binaryName is what a compiled bundle's executable is called: what the artifact says, or the name of
|
||||||
// the package it is built from, which is what a compiler would have chosen anyway.
|
// the package it is built from, which is what a compiler would have chosen anyway.
|
||||||
func binaryName(a catalogue.Artifact) string {
|
func binaryName(a catalogue.Artifact) string {
|
||||||
|
if name := catalogue.BinaryOf(a); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
if name := strings.TrimSpace(a.Binary); name != "" {
|
if name := strings.TrimSpace(a.Binary); name != "" {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
@@ -1173,3 +1182,45 @@ func binaryName(a catalogue.Artifact) string {
|
|||||||
}
|
}
|
||||||
return a.Name
|
return a.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// launcherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is
|
||||||
|
// started as index.serve.mjs (novox/hq ADR 0193). An ES module by its own extension, whatever the
|
||||||
|
// bundle's package.json says.
|
||||||
|
const launcherSuffix = ".serve.mjs"
|
||||||
|
|
||||||
|
// writeLaunchers writes, beside every entrypoint of a TypeScript bundle, an executable that
|
||||||
|
// imports the entrypoint and serves what it registered over MCP on stdio — through the bundle's
|
||||||
|
// own copy of the SDK, so registering and serving meet in one registry (novox/hq ADR 0193). Its
|
||||||
|
// answer is each entrypoint's launcher, by entrypoint, relative to the bundle's root; nothing for
|
||||||
|
// a language whose build is already executable.
|
||||||
|
func writeLaunchers(root string, chain Toolchain, a catalogue.Artifact) (map[string]string, error) {
|
||||||
|
if chain.Language != "typescript" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, entry := range a.Entrypoints {
|
||||||
|
if !strings.HasSuffix(entry, ".js") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix
|
||||||
|
body := "#!/usr/bin/env node\n" +
|
||||||
|
"// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" +
|
||||||
|
"// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" +
|
||||||
|
"import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\n" +
|
||||||
|
"await import(\"./" + filepath.Base(entry) + "\");\n" +
|
||||||
|
"await serveRegisteredOverStdio();\n"
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(launcher))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// WriteFile honours the umask; the mode a machine unpacks is the one packed, so it is set.
|
||||||
|
if err := os.Chmod(path, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[entry] = launcher
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0193: every entrypoint the runtime may serve is executable, and the runtime knows no
|
||||||
|
// language — so a TypeScript bundle carries a launcher beside each entrypoint.
|
||||||
|
func TestATypeScriptBundleCarriesAnExecutableLauncherBesideEachEntrypoint(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
chain, err := ToolchainFor("typescript")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := writeLaunchers(root, chain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||||
|
Language: "typescript", Entrypoints: []string{"tools/index.js", "index.js"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got["tools/index.js"] != "tools/index.serve.mjs" || got["index.js"] != "index.serve.mjs" {
|
||||||
|
t.Fatalf("launchers: %v", got)
|
||||||
|
}
|
||||||
|
path := filepath.Join(root, "tools", "index.serve.mjs")
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o755 {
|
||||||
|
t.Errorf("the launcher is %v, not executable 0755", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
body, _ := os.ReadFile(path)
|
||||||
|
for _, want := range []string{"#!/usr/bin/env node\n", `from "@novox/mesh-sdk/stdio"`, `await import("./index.js")`, "serveRegisteredOverStdio()"} {
|
||||||
|
if !strings.Contains(string(body), want) {
|
||||||
|
t.Errorf("the launcher lacks %q:\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A compiled language's build is executable already: no launcher.
|
||||||
|
goChain, _ := ToolchainFor("go")
|
||||||
|
none, err := writeLaunchers(t.TempDir(), goChain, catalogue.Artifact{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "go"})
|
||||||
|
if err != nil || len(none) != 0 {
|
||||||
|
t.Errorf("a Go bundle was given launchers: %v %v", none, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package catalogue
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -28,6 +29,9 @@ type Built struct {
|
|||||||
Reference string
|
Reference string
|
||||||
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
|
// Digest is "sha256:<hex>", for an archive. An image reference already ends in one.
|
||||||
Digest string
|
Digest string
|
||||||
|
// Launchers are, for a bundle in an interpreted language, the executable the build wrote beside
|
||||||
|
// each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it.
|
||||||
|
Launchers map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve fills a manifest's resources in from what was built.
|
// Resolve fills a manifest's resources in from what was built.
|
||||||
@@ -92,7 +96,8 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
out.Bundles = append(out.Bundles, Bundle{
|
out.Bundles = append(out.Bundles, Bundle{
|
||||||
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
Name: a.Name, Source: Recorded(made.Reference), Digest: made.Digest,
|
||||||
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...),
|
||||||
Loads: loads,
|
Loads: loads, Env: copyWords(a.Env), Launchers: copyWords(made.Launchers),
|
||||||
|
Binary: BinaryOf(a),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name })
|
||||||
@@ -203,6 +208,12 @@ 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
|
// 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
|
// is which compiler — because the mesh chooses that, and cannot choose for a module that
|
||||||
// has not said.
|
// has not said.
|
||||||
|
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 "+
|
||||||
|
"serves is given words (novox/hq ADR 0192); a container says its own environment",
|
||||||
|
module, a.Name, a.Kind))
|
||||||
|
}
|
||||||
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
|
||||||
// **Except for a language that compiles to a binary, where it names which one**
|
// **Except for a language that compiles to a binary, where it names which one**
|
||||||
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
|
||||||
@@ -222,6 +233,7 @@ func (b *Build) problems(module string) []string {
|
|||||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
||||||
"for it", module, a.Name))
|
"for it", module, a.Name))
|
||||||
}
|
}
|
||||||
|
problems = append(problems, bundleEnvProblems(module, a)...)
|
||||||
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
|
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
|
||||||
// not an entrypoint is a file the bundle does not contain, and the runtime would
|
// not an entrypoint is a file the bundle does not contain, and the runtime would
|
||||||
// fail to import it on every machine rather than here.
|
// fail to import it on every machine rather than here.
|
||||||
@@ -360,3 +372,67 @@ func versionOf(digest string) string {
|
|||||||
}
|
}
|
||||||
return hex
|
return hex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be
|
||||||
|
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
|
||||||
|
var bundleEnvWords = map[string]bool{
|
||||||
|
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
|
||||||
|
RuntimeOperatorHome: true, RuntimeToolEnv: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
|
||||||
|
// a value is a path or a constant written with the references a container's environment may use
|
||||||
|
// for a place or a port, and never a secret's content or another module's binding — a secret
|
||||||
|
// reaches a tool as a file whose path is named.
|
||||||
|
func bundleEnvProblems(module string, a Artifact) []string {
|
||||||
|
if len(a.Env) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var problems []string
|
||||||
|
for _, word := range sortedKeys(a.Env) {
|
||||||
|
value := a.Env[word]
|
||||||
|
if bundleEnvWords[word] {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+
|
||||||
|
"given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)",
|
||||||
|
module, a.Name, word))
|
||||||
|
}
|
||||||
|
rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "")
|
||||||
|
if strings.Contains(rest, "${") {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+
|
||||||
|
"${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+
|
||||||
|
"named by its path, never as its content (novox/hq ADR 0192)",
|
||||||
|
module, a.Name, word, value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyWords(in map[string]string) map[string]string {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(in))
|
||||||
|
for k, v := range in {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinaryOf is what a bundle compiled to a binary is called once built: what the artifact names, else
|
||||||
|
// the package it is built from, else the artifact's own name (novox/hq 04-ISSUES/142). Empty for a
|
||||||
|
// language that does not compile to one. The builder writes the binary under this name, and the
|
||||||
|
// composer runs it by it, so both ask here.
|
||||||
|
func BinaryOf(a Artifact) string {
|
||||||
|
if !compilesToABinary(a.Language) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if name := strings.TrimSpace(a.Binary); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if from := strings.Trim(a.From, "./"); from != "" {
|
||||||
|
return path.Base(from)
|
||||||
|
}
|
||||||
|
return a.Name
|
||||||
|
}
|
||||||
|
|||||||
@@ -921,6 +921,30 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
}
|
}
|
||||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||||
out = append(out, process)
|
out = append(out, process)
|
||||||
|
// What each module's bundles are given is read as the account the runtime runs as.
|
||||||
|
words := map[string]map[string]string{}
|
||||||
|
for _, m := range r.Modules {
|
||||||
|
w, err := bundleWords(m, with)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
words[m.Module] = w
|
||||||
|
}
|
||||||
|
// And a file a module's words name is one the runtime is restarted for when it changes.
|
||||||
|
if named := givenTo(out, owner, words, r.Account); len(named) > 0 {
|
||||||
|
restarts, _ := process["restart-on"].([]any)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, id := range restarts {
|
||||||
|
seen[fmt.Sprint(id)] = true
|
||||||
|
}
|
||||||
|
for _, id := range named {
|
||||||
|
if !seen[id] {
|
||||||
|
restarts = append(restarts, id)
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process["restart-on"] = restarts
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if with.Adopted {
|
if with.Adopted {
|
||||||
// First, before anything a module declares: what the mesh needs reachable, then its guard.
|
// First, before anything a module declares: what the mesh needs reachable, then its guard.
|
||||||
|
|||||||
@@ -602,6 +602,14 @@ type Bundle struct {
|
|||||||
// every entrypoint for a module declaring tools that said nothing. Empty for a bundle that is
|
// every entrypoint for a module declaring tools that said nothing. Empty for a bundle that is
|
||||||
// run rather than loaded.
|
// run rather than loaded.
|
||||||
Loads []string `json:"loads,omitempty"`
|
Loads []string `json:"loads,omitempty"`
|
||||||
|
// Env is what the artifact said it is given (ADR 0192), as written; composed per machine.
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
// Launchers are the executables the build wrote beside its entrypoints, by entrypoint (novox/hq
|
||||||
|
// ADR 0193). A bundle built before them has none, and is served as it was built.
|
||||||
|
Launchers map[string]string `json:"launchers,omitempty"`
|
||||||
|
// Binary is the executable a bundle compiled to a binary is, at its root (novox/hq ADR 0193):
|
||||||
|
// what runs it, where an interpreted bundle names an interpreter and an entrypoint.
|
||||||
|
Binary string `json:"binary,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build says how to produce this module's artifacts from its source.
|
// Build says how to produce this module's artifacts from its source.
|
||||||
@@ -748,6 +756,11 @@ type Artifact struct {
|
|||||||
// module's tools and nothing else is the ordinary case and should not have to say the same
|
// module's tools and nothing else is the ordinary case and should not have to say the same
|
||||||
// list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles.
|
// list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles.
|
||||||
Loads []string `json:"loads,omitempty"`
|
Loads []string `json:"loads,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.
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kinds an artifact may be.
|
// Kinds an artifact may be.
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Which machine serves each routed name (novox/hq ADR 0066, issue 178).
|
|
||||||
//
|
|
||||||
// A routed name is a label the mesh composed for a consumer's endpoint, and it is *served* by the
|
|
||||||
// provider that answers requests for it — the proxy the consumer's route reaches. The same name is
|
|
||||||
// composed into every labelled contribution the consumer makes, because a provider that must know
|
|
||||||
// the consumer's public name (an identity provider composing a redirect) is told it the same way
|
|
||||||
// (04-ISSUES/122). Attributing the name to whichever of those providers a map happened to yield
|
|
||||||
// last sent a public name to the identity provider's machine on one plan and to the proxy's on the
|
|
||||||
// next (forge issue 227), and the whole names region flipped with it.
|
|
||||||
//
|
|
||||||
// **The terminus serves the name.** Among the providers a name reaches, the one that serves it is
|
|
||||||
// the one that is not itself routed: a provider that contributes a labelled name of its own to some
|
|
||||||
// requirement is published through another provider, and is a consumer of names, not their end.
|
|
||||||
// Name-agnostic — nothing here knows what "route" means — and structural: it reads the graph the
|
|
||||||
// modules declared. Deterministic: names, requirements and nodes are walked in order, so two
|
|
||||||
// plans of one mesh yield one region.
|
|
||||||
|
|
||||||
// NamesServed is every routed name across the mesh and the node that serves it, from every node's
|
|
||||||
// resolution and settings. A name several termini claim goes to the first node in name order, so
|
|
||||||
// the answer is stable; a name nothing terminal claims is left out.
|
|
||||||
func NamesServed(plans map[string]Resolution, settings map[string]SettingsBy) (map[string]string, error) {
|
|
||||||
nodes := make([]string, 0, len(plans))
|
|
||||||
for n := range plans {
|
|
||||||
nodes = append(nodes, n)
|
|
||||||
}
|
|
||||||
sort.Strings(nodes)
|
|
||||||
|
|
||||||
out := map[string]string{}
|
|
||||||
for _, node := range nodes {
|
|
||||||
plan := plans[node]
|
|
||||||
all, err := plan.contributions(settings[node], nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
requirements := make([]string, 0, len(all))
|
|
||||||
for to := range all {
|
|
||||||
requirements = append(requirements, to)
|
|
||||||
}
|
|
||||||
sort.Strings(requirements)
|
|
||||||
for _, to := range requirements {
|
|
||||||
for _, given := range all[to] {
|
|
||||||
if given.Node != "" {
|
|
||||||
// Said from another machine; that machine's own resolution carries it.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// A routed name, and only that: a contribution the mesh composed a name for from a
|
|
||||||
// label it was given. A grant that happens to carry a `name` of its own — a database
|
|
||||||
// name — carries no label and is left alone.
|
|
||||||
if _, labelled := given.Values["label"]; !labelled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name, _ := given.Values["name"].(string)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
serving := servingNodeOf(plan, to, given.From, node)
|
|
||||||
if !servesNames(plans[serving], to) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name = strings.ToLower(name)
|
|
||||||
if held, taken := out[name]; !taken || serving < held {
|
|
||||||
out[name] = serving
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// servingNodeOf is the node answering one consumer's requirement: whoever the plan needs it from,
|
|
||||||
// or this same node when the provider is beside the consumer.
|
|
||||||
func servingNodeOf(plan Resolution, requirement, consumer, self string) string {
|
|
||||||
for _, need := range plan.Needs {
|
|
||||||
if need.Name == requirement && need.For == consumer && need.From != "" {
|
|
||||||
return need.From
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return self
|
|
||||||
}
|
|
||||||
|
|
||||||
// servesNames says whether the module providing a requirement on a node is a terminus: it is not
|
|
||||||
// itself published under a labelled name through some other provider. A node whose plan is not
|
|
||||||
// known (it did not resolve) serves nothing.
|
|
||||||
func servesNames(plan Resolution, requirement string) bool {
|
|
||||||
for _, m := range plan.Modules {
|
|
||||||
if !offers(m, requirement) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return !contributesALabel(m)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func offers(m Manifest, requirement string) bool {
|
|
||||||
for _, o := range m.Offers() {
|
|
||||||
if o == requirement {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func contributesALabel(m Manifest) bool {
|
|
||||||
for _, values := range m.Contributes {
|
|
||||||
if _, labelled := values["label"]; labelled {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, locals := range m.ContributesMany {
|
|
||||||
for _, values := range locals {
|
|
||||||
if _, labelled := values["label"]; labelled {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The mesh of forge issue 227 (novox/hq issue 178): a dashboard on the home server contributes its
|
|
||||||
// label to the route its proxy serves AND to the identity provider on the control node, which must
|
|
||||||
// know the dashboard's public name to compose a redirect. Both contributions carry the composed
|
|
||||||
// name; only the proxy serves it.
|
|
||||||
func twoNodesOneName(t *testing.T) (map[string]Resolution, map[string]SettingsBy) {
|
|
||||||
t.Helper()
|
|
||||||
catalogue := shelf(
|
|
||||||
Manifest{Module: "route-adapter", Version: "1", Provides: Offers("route"),
|
|
||||||
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/adapter/mesh.json"}},
|
|
||||||
Manifest{Module: "route-proxy", Version: "1", Provides: Offers("route"),
|
|
||||||
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/proxy/mesh.json"}},
|
|
||||||
Manifest{Module: "keycloak", Version: "1", Provides: FromAnywhere("oidc-client"),
|
|
||||||
Serves: map[string]map[string]any{"oidc-client": {"token-path": "/token"}},
|
|
||||||
Receives: map[string]string{"oidc-client": "/etc/keycloak/clients.json"},
|
|
||||||
Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page"}},
|
|
||||||
// Published through the proxy itself: the identity provider is routed, not a router.
|
|
||||||
Contributes: map[string]map[string]any{"route": {"label": "login", "endpoint": "web", "port": 8080}}},
|
|
||||||
Manifest{Module: "grafana", Version: "1",
|
|
||||||
Listens: []Listening{{Port: 3000, From: FromMesh, Why: "dashboards"}},
|
|
||||||
Contributes: map[string]map[string]any{
|
|
||||||
"route": {"label": "grafana", "endpoint": "web", "port": 3000},
|
|
||||||
"oidc-client": {"label": "grafana", "endpoint": "web", "port": 3000, "callback": "/login"},
|
|
||||||
}},
|
|
||||||
)
|
|
||||||
home := withDomain("home.example")
|
|
||||||
home.Name, home.At = "home-server", "home-server.internal"
|
|
||||||
control := withDomain("control.example")
|
|
||||||
control.Name, control.At = "anchor", "anchor.internal"
|
|
||||||
|
|
||||||
onHome, err := Resolve(catalogue, []string{"grafana", "route-adapter"}, home, World{
|
|
||||||
Offered: map[string][]Provider{"oidc-client": {{Node: "anchor", At: "anchor.internal", Module: "keycloak"}}},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
onControl, err := Resolve(catalogue, []string{"keycloak", "route-proxy"}, control, World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return map[string]Resolution{"home-server": onHome, "anchor": onControl},
|
|
||||||
map[string]SettingsBy{"home-server": {}, "anchor": {}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestANameResolvesToTheNodeWhoseProxyServesIt(t *testing.T) {
|
|
||||||
plans, settings := twoNodesOneName(t)
|
|
||||||
// Many times, because the fault was map order: one plan said one node, the next the other.
|
|
||||||
for i := 0; i < 25; i++ {
|
|
||||||
served, err := NamesServed(plans, settings)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if served["grafana.home.example"] != "home-server" {
|
|
||||||
t.Fatalf("run %d: the dashboard's name is served by %q, and its proxy is on the home server: %v",
|
|
||||||
i, served["grafana.home.example"], served)
|
|
||||||
}
|
|
||||||
if served["login.control.example"] != "anchor" {
|
|
||||||
t.Fatalf("run %d: the identity provider's own name is served by its proxy on the control node: %v", i, served)
|
|
||||||
}
|
|
||||||
if _, leaked := served["grafana.control.example"]; leaked {
|
|
||||||
t.Fatalf("a name composed for the identity provider's benefit is not one it serves: %v", served)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module that is routed several times names each route (ADR 0094's sibling for contributes);
|
|
||||||
// every one of them is a name the mesh must resolve, and none reached the names region before.
|
|
||||||
func TestEveryRouteOfAModuleWithSeveralIsANameServed(t *testing.T) {
|
|
||||||
catalogue := shelf(
|
|
||||||
Manifest{Module: "route-proxy", Version: "1", Provides: Offers("route"),
|
|
||||||
Serves: map[string]map[string]any{"route": {}}, Receives: map[string]string{"route": "/etc/proxy/mesh.json"}},
|
|
||||||
Manifest{Module: "photos", Version: "1",
|
|
||||||
Listens: []Listening{{Port: 8102, From: FromMesh, Why: "web"}, {Port: 9102, From: FromMesh, Why: "api"}},
|
|
||||||
ContributesMany: map[string]map[string]map[string]any{"route": {
|
|
||||||
"site": {"label": "photos", "endpoint": "web", "port": 8102},
|
|
||||||
"api": {"label": "photos-api", "endpoint": "api", "port": 9102},
|
|
||||||
}}},
|
|
||||||
)
|
|
||||||
node := withDomain("control.example")
|
|
||||||
node.Name, node.At = "anchor", "anchor.internal"
|
|
||||||
plan, err := Resolve(catalogue, []string{"photos", "route-proxy"}, node, World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
served, err := NamesServed(map[string]Resolution{"anchor": plan}, map[string]SettingsBy{"anchor": {}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, name := range []string{"photos.control.example", "photos-api.control.example"} {
|
|
||||||
if served[name] != "anchor" {
|
|
||||||
t.Fatalf("%s is not served by its proxy: %v", name, served)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,10 +28,10 @@ import (
|
|||||||
|
|
||||||
// A RosterFile is a file the mesh renders from the roster of machines, in the format the module
|
// A RosterFile is a file the mesh renders from the roster of machines, in the format the module
|
||||||
// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name),
|
// gives as a Go text/template. The template sees a rosterView: `.Node` (this machine's bare name),
|
||||||
// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names`, every
|
// `.Suffix` (what its mesh name ends in), and two sets of `{Name, FQDN, Address}` — `.Names` and
|
||||||
// name the mesh serves, and `.Machines`, only the nodes of the mesh. Which set a template ranges is
|
// `.Machines`. Both are the nodes of the mesh: routed names were once in `.Names` too, and are not
|
||||||
// how the hq issue 111 distinction is drawn: a container's hosts wants every name; a resolver told
|
// since every route became a name under its node's internal domain (novox/hq ADR 0191) — the hq
|
||||||
// the suffix is its own wants only the machines.
|
// issue 111 distinction is kept as two fields so the templates that range either keep rendering.
|
||||||
type RosterFile struct {
|
type RosterFile struct {
|
||||||
// Path is where on the machine the rendered file goes. Absolute, or it is refused here rather
|
// Path is where on the machine the rendered file goes. Absolute, or it is refused here rather
|
||||||
// than discovered as a daemon that reads nothing.
|
// than discovered as a daemon that reads nothing.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -82,6 +83,11 @@ const (
|
|||||||
RuntimeBrokerFile = "MESH_BROKER_FILE"
|
RuntimeBrokerFile = "MESH_BROKER_FILE"
|
||||||
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT"
|
||||||
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
|
RuntimeOperatorHome = "MESH_OPERATOR_HOME"
|
||||||
|
// RuntimeToolEnv is every served module's composed environment, as JSON (novox/hq ADR 0192):
|
||||||
|
// {"<module>": {"<word>": "<value>"}}. The runtime takes it at start, removes it from its own
|
||||||
|
// environment and hands each module's words to that module's bundles alone. In the unit, so a
|
||||||
|
// change to any module's words changes the process and restarts it.
|
||||||
|
RuntimeToolEnv = "MESH_TOOL_ENV"
|
||||||
)
|
)
|
||||||
|
|
||||||
// interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the
|
// interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the
|
||||||
@@ -123,14 +129,22 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|||||||
RuntimeModule, r.Node, len(runtime.Bundles))
|
RuntimeModule, r.Node, len(runtime.Bundles))
|
||||||
}
|
}
|
||||||
bundle := runtime.Bundles[0]
|
bundle := runtime.Bundles[0]
|
||||||
if len(bundle.Entrypoints) != 1 {
|
// What runs it (novox/hq ADR 0193): a runtime compiled to a binary runs itself, from its own
|
||||||
return nil, fmt.Errorf(
|
// unpacked bundle; an interpreted one is its language's interpreter and its one entrypoint.
|
||||||
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
|
var run []any
|
||||||
"declares exactly one (novox/hq to-be 38)", RuntimeModule, bundle.Name, len(bundle.Entrypoints))
|
if bundle.Binary != "" {
|
||||||
}
|
run = []any{"./" + bundle.Binary}
|
||||||
interpreter, err := interpreterFor(bundle.Language)
|
} else {
|
||||||
if err != nil {
|
if len(bundle.Entrypoints) != 1 {
|
||||||
return nil, err
|
return nil, fmt.Errorf(
|
||||||
|
"%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+
|
||||||
|
"declares exactly one (novox/hq to-be 38)", RuntimeModule, bundle.Name, len(bundle.Entrypoints))
|
||||||
|
}
|
||||||
|
interpreter, err := interpreterFor(bundle.Language)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
run = []any{interpreter, bundle.Entrypoints[0]}
|
||||||
}
|
}
|
||||||
credential, declared := runtime.OwnSecrets["broker"]
|
credential, declared := runtime.OwnSecrets["broker"]
|
||||||
if !declared {
|
if !declared {
|
||||||
@@ -146,15 +160,28 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|||||||
// would be told to load files that were never delivered.
|
// would be told to load files that were never delivered.
|
||||||
var served []string
|
var served []string
|
||||||
var restartOn []string
|
var restartOn []string
|
||||||
|
given := map[string]map[string]string{}
|
||||||
for _, m := range r.Modules {
|
for _, m := range r.Modules {
|
||||||
if with.Adopted && m.Filtering != nil {
|
if with.Adopted && m.Filtering != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
words, err := bundleWords(m, with)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(words) > 0 {
|
||||||
|
given[m.Module] = words
|
||||||
|
}
|
||||||
for _, b := range m.Bundles {
|
for _, b := range m.Bundles {
|
||||||
if len(b.Loads) == 0 {
|
if len(b.Loads) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, load := range b.Loads {
|
for _, load := range b.Loads {
|
||||||
|
// What the runtime starts: the launcher the build wrote beside the entrypoint, where
|
||||||
|
// it wrote one (ADR 0193); the entrypoint itself for a build from before them.
|
||||||
|
if launcher, has := b.Launchers[load]; has {
|
||||||
|
load = launcher
|
||||||
|
}
|
||||||
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load)
|
||||||
}
|
}
|
||||||
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
restartOn = append(restartOn, m.Module+"."+BundleID(b.Name))
|
||||||
@@ -168,10 +195,18 @@ func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) {
|
|||||||
RuntimeToolModules: strings.Join(served, ","),
|
RuntimeToolModules: strings.Join(served, ","),
|
||||||
RuntimeBrokerFile: credential.Path,
|
RuntimeBrokerFile: credential.Path,
|
||||||
}
|
}
|
||||||
|
if len(given) > 0 {
|
||||||
|
// Marshalled from maps, whose keys encoding/json sorts: the same words, the same unit.
|
||||||
|
body, err := json.Marshal(given)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
env[RuntimeToolEnv] = string(body)
|
||||||
|
}
|
||||||
process := map[string]any{
|
process := map[string]any{
|
||||||
"id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule,
|
"id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule,
|
||||||
"source": bundle.Source, "digest": bundle.Digest,
|
"source": bundle.Source, "digest": bundle.Digest,
|
||||||
"run": []any{interpreter, bundle.Entrypoints[0]},
|
"run": run,
|
||||||
"env": env,
|
"env": env,
|
||||||
"restart-on": toAny(restartOn),
|
"restart-on": toAny(restartOn),
|
||||||
}
|
}
|
||||||
@@ -249,3 +284,82 @@ func ToolContainerOnTheRuntime(m Manifest, against []string) string {
|
|||||||
"now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container",
|
"now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container",
|
||||||
m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule)
|
m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bundleWords is what one module's tools bundles are given on this machine (novox/hq ADR 0192):
|
||||||
|
// each loaded bundle's env, its ${dir:…} resolved to where this machine places the module's
|
||||||
|
// directories and its ${port:…} to the port this machine gave it — the same resolution a
|
||||||
|
// container's environment gets. Two bundles of one module naming one word differently is refused:
|
||||||
|
// the runtime hands a module's words to all its bundles.
|
||||||
|
func bundleWords(m Manifest, with Rendering) (map[string]string, error) {
|
||||||
|
var out map[string]string
|
||||||
|
dirs := dirsFor(m, with)
|
||||||
|
for _, b := range m.Bundles {
|
||||||
|
if len(b.Loads) == 0 || len(b.Env) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, word := range sortedKeys(b.Env) {
|
||||||
|
value, err := dirFill(b.Env[word], dirs, m.Module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = map[string]string{}
|
||||||
|
}
|
||||||
|
if was, had := out[word]; had && was != value {
|
||||||
|
return nil, fmt.Errorf("%s's bundles give %s two values (%q, %q); a module's words are "+
|
||||||
|
"handed to all its bundles, so they agree (novox/hq ADR 0192)", m.Module, word, was, value)
|
||||||
|
}
|
||||||
|
out[word] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// givenTo makes what a bundle's words name readable by the account the runtime runs as (novox/hq
|
||||||
|
// ADR 0192): every file and directory of the module whose path a word names, or that holds one,
|
||||||
|
// is owned by the account — a tool reads its configuration and its secret as the account, and a
|
||||||
|
// root-owned 0600 file or a 0700 directory is one it cannot. Only where it says no owner already:
|
||||||
|
// a module that named one knew why. Nothing on a machine with no account, where the runtime is root.
|
||||||
|
//
|
||||||
|
// It answers the files a word names exactly: what a tool reads, whose change the runtime must be
|
||||||
|
// restarted for, as the container the tools came from was restarted when its configuration changed.
|
||||||
|
func givenTo(out []map[string]any, owner map[string]string, words map[string]map[string]string, account string) []string {
|
||||||
|
var named []string
|
||||||
|
if len(words) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, resource := range out {
|
||||||
|
module := owner[fmt.Sprint(resource["id"])]
|
||||||
|
mine := words[module]
|
||||||
|
if len(mine) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kind := fmt.Sprint(resource["type"])
|
||||||
|
if kind != "file" && kind != "directory" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path, _ := resource["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, value := range mine {
|
||||||
|
if kind == "file" && value == path {
|
||||||
|
named = append(named, fmt.Sprint(resource["id"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, said := resource["owner"]; said || account == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, value := range mine {
|
||||||
|
if value == path || strings.HasPrefix(value, strings.TrimRight(path, "/")+"/") {
|
||||||
|
resource["owner"] = account
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(named)
|
||||||
|
return named
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -210,3 +211,181 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0192: a tools bundle says what it is given; the composer resolves it per machine as
|
||||||
|
// a container's environment, hands it to the runtime as the module's words, and makes what the
|
||||||
|
// words name readable by the account the runtime runs as.
|
||||||
|
func TestABundleIsGivenItsWordsResolvedForThisMachine(t *testing.T) {
|
||||||
|
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||||
|
Needed: map[string]map[string]string{
|
||||||
|
RuntimeModule: {"broker": "sealed-credential"},
|
||||||
|
"dash": {"token": "sealed-token"},
|
||||||
|
}}
|
||||||
|
dash := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
||||||
|
Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp"}},
|
||||||
|
OwnSecrets: OwnSecrets{"token": {Path: "${dir:mesh-state}/token"}},
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
|
||||||
|
{"id": "config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", "content": "{}\n"},
|
||||||
|
{"id": "unrelated", "type": "file", "path": "/etc/dash.conf", "content": "x\n"},
|
||||||
|
},
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
|
||||||
|
Entrypoints: []string{"tools/index.js"},
|
||||||
|
Env: map[string]string{
|
||||||
|
"DASH_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||||
|
"DASH_TOKEN_FILE": "${dir:mesh-state}/token",
|
||||||
|
"DASH_URL": "http://127.0.0.1:${port:3000}",
|
||||||
|
"DASH_ADMIN": "mesh-admin",
|
||||||
|
}}}}}
|
||||||
|
if problems := dash.Build.problems(dash.Module); len(problems) > 0 {
|
||||||
|
t.Fatalf("a bundle's words written with ${dir:…} and ${port:…} were refused: %v", problems)
|
||||||
|
}
|
||||||
|
dash, err := dash.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||||
|
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other := aToolsModule(t, "nftables", "tools/index.js")
|
||||||
|
|
||||||
|
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{dash, other, theRuntime(t)}}.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := fileNamed(out, "dash.mesh-state")
|
||||||
|
if dir == nil {
|
||||||
|
t.Fatalf("no directory: %v", ids(out))
|
||||||
|
}
|
||||||
|
at := fmt.Sprint(dir["path"])
|
||||||
|
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||||
|
env := process["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])
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"DASH_CONFIG_FILE": at + "/config.json",
|
||||||
|
"DASH_TOKEN_FILE": at + "/token",
|
||||||
|
"DASH_URL": "http://127.0.0.1:3000",
|
||||||
|
"DASH_ADMIN": "mesh-admin",
|
||||||
|
}
|
||||||
|
if fmt.Sprint(given["dash"]) != fmt.Sprint(want) {
|
||||||
|
t.Errorf("dash is given %v, want %v", given["dash"], want)
|
||||||
|
}
|
||||||
|
if _, has := given["nftables"]; has {
|
||||||
|
t.Errorf("a module that declares no words was given some: %v", given)
|
||||||
|
}
|
||||||
|
// What the words name is the account's to read; nothing else of the module's is touched.
|
||||||
|
for _, id := range []string{"dash.mesh-state", "dash.config", "dash." + NeedID("token")} {
|
||||||
|
if r := fileNamed(out, id); r == nil || r["owner"] != "ops" {
|
||||||
|
t.Errorf("%s is not the account's to read: %v", id, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r := fileNamed(out, "dash.unrelated"); r == nil || r["owner"] != nil {
|
||||||
|
t.Errorf("a file no word names was given an owner: %v", r)
|
||||||
|
}
|
||||||
|
// A file a word names restarts the runtime when it changes, as it restarted the tool container.
|
||||||
|
restarts := fmt.Sprint(process["restart-on"])
|
||||||
|
for _, want := range []string{"dash.config", "dash." + NeedID("token")} {
|
||||||
|
if !strings.Contains(restarts, want) {
|
||||||
|
t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(restarts, "dash.unrelated") || strings.Contains(restarts, "dash.mesh-state") {
|
||||||
|
t.Errorf("the runtime restarts for something no word names as a file: %s", restarts)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("on a machine with no account the runtime is root and nothing is re-owned", func(t *testing.T) {
|
||||||
|
out, err := Resolution{Node: "anchor", Modules: []Manifest{dash, theRuntime(t)}}.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r := fileNamed(out, "dash.config"); r["owner"] != nil {
|
||||||
|
t.Errorf("re-owned with no account: %v", r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a change to a module's words changes the runtime's process", func(t *testing.T) {
|
||||||
|
changed := dash
|
||||||
|
changed.Bundles = append([]Bundle(nil), dash.Bundles...)
|
||||||
|
changed.Bundles[0].Env = map[string]string{"DASH_ADMIN": "somebody-else"}
|
||||||
|
out2, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{changed, theRuntime(t)}}.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(fileNamed(out2, RuntimeModule+"."+RuntimeProcessID())["env"]) == fmt.Sprint(env) {
|
||||||
|
t.Error("the runtime's process is the same after a module's words changed, so it would not restart")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) {
|
||||||
|
m := Manifest{Module: "dash", Version: "1", Build: &Build{Artifacts: []Artifact{
|
||||||
|
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"},
|
||||||
|
Env: map[string]string{"DASH_TOKEN": "${secret:token}", RuntimeBrokerFile: "/x", "DASH_PEER": "${bound:db:url}"}},
|
||||||
|
{Name: "runtime", Kind: ArtifactImage, From: "Dockerfile", Env: map[string]string{"X": "y"}},
|
||||||
|
}}}
|
||||||
|
said := strings.Join(m.Build.problems(m.Module), "\n")
|
||||||
|
for _, want := range []string{
|
||||||
|
`"tools" gives DASH_TOKEN the value "${secret:token}"`,
|
||||||
|
`"tools" gives DASH_PEER the value "${bound:db:url}"`,
|
||||||
|
`"tools" gives itself ` + RuntimeBrokerFile,
|
||||||
|
`"runtime" is a "image" and says what it is given`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(said, want) {
|
||||||
|
t.Errorf("not refused: %s\nsaid:\n%s", want, said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0193: the runtime is told the launcher a build wrote, and the entrypoint itself for a
|
||||||
|
// build from before launchers — so the move needs no flag day.
|
||||||
|
func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) {
|
||||||
|
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||||
|
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||||
|
launched := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
|
||||||
|
Entrypoints: []string{"tools/index.js"}}}}}
|
||||||
|
launched, err := launched.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||||
|
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||||
|
Launchers: map[string]string{"tools/index.js": "tools/index.serve.mjs"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
older := aToolsModule(t, "nftables", "tools/index.js")
|
||||||
|
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{launched, older, theRuntime(t)}}.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
|
||||||
|
want := "dash=" + BundleRoot + "/dash/tools/tools/index.serve.mjs,nftables=" + BundleRoot + "/nftables/tools/tools/index.js"
|
||||||
|
if env[RuntimeToolModules] != want {
|
||||||
|
t.Errorf("the runtime is told %q, want %q", env[RuntimeToolModules], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0193: a runtime compiled to a binary runs itself from its own unpacked bundle.
|
||||||
|
func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) {
|
||||||
|
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||||
|
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||||
|
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||||
|
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||||
|
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||||
|
System: "arch", From: "cmd/node-tools"}}}}
|
||||||
|
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||||
|
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{aToolsModule(t, "nftables", "tools/index.js"), goRuntime}}.Declaration(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||||
|
if fmt.Sprint(process["run"]) != "[./node-tools]" {
|
||||||
|
t.Errorf("a Go runtime is run as %v, want its own binary", process["run"])
|
||||||
|
}
|
||||||
|
env := process["env"].(map[string]string)
|
||||||
|
if env[RuntimeToolModules] == "" || process["user"] != "ops" {
|
||||||
|
t.Errorf("the Go runtime is not told what to serve or whose it is: %v %v", env, process["user"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/micro"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What answers announces itself (novox/hq ADR 0197). A holder that serves a seat's verbs answers the
|
||||||
|
// NATS services protocol's discovery — `$SRV.PING` and `$SRV.INFO`, and each by its service's name
|
||||||
|
// and instance — with exactly what it serves, in NATS's own format, so the console and the standard
|
||||||
|
// `nats micro` commands learn what exists from what answers rather than from a roster.
|
||||||
|
|
||||||
|
// DiscoverySubjects are where one service instance is asked to say what it is.
|
||||||
|
func DiscoverySubjects(name, id string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, verb := range []string{"PING", "INFO"} {
|
||||||
|
out = append(out, "$SRV."+verb, "$SRV."+verb+"."+name, "$SRV."+verb+"."+name+"."+id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce answers discovery for one service until stopped. The answer is fixed at the call: a holder
|
||||||
|
// whose verbs change announces again. Every instance answers, so there is no queue group.
|
||||||
|
func (b OverNATS) Announce(info micro.Info, logger *log.Logger) (func(), error) {
|
||||||
|
info.Type = micro.InfoResponseType
|
||||||
|
infoBody, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pingBody, err := json.Marshal(micro.Ping{ServiceIdentity: info.ServiceIdentity, Type: micro.PingResponseType})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var subs []*nats.Subscription
|
||||||
|
done := make(chan struct{})
|
||||||
|
stop := func() {
|
||||||
|
close(done)
|
||||||
|
for _, s := range subs {
|
||||||
|
_ = s.Unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, subject := range DiscoverySubjects(info.Name, info.ID) {
|
||||||
|
subject := subject
|
||||||
|
body := infoBody
|
||||||
|
if len(subject) >= 9 && subject[:9] == "$SRV.PING" {
|
||||||
|
body = pingBody
|
||||||
|
}
|
||||||
|
bind := func() (*nats.Subscription, error) {
|
||||||
|
return b.Conn.Subscribe(subject, func(msg *nats.Msg) {
|
||||||
|
if err := msg.Respond(body); err != nil && logger != nil {
|
||||||
|
logger.Printf("%s: could not answer: %v", subject, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sub, err := bind()
|
||||||
|
if err != nil {
|
||||||
|
stop()
|
||||||
|
return nil, fmt.Errorf("announcing %s on %s: %w", info.Name, subject, err)
|
||||||
|
}
|
||||||
|
subs = append(subs, sub)
|
||||||
|
go keepBound(sub, bind, subject, done, logger)
|
||||||
|
}
|
||||||
|
return stop, nil
|
||||||
|
}
|
||||||
@@ -193,9 +193,15 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
|
|||||||
// machine with nothing to build, and is asked again.
|
// machine with nothing to build, and is asked again.
|
||||||
fetched, err := sub.Fetch(1, nats.Context(ctx))
|
fetched, err := sub.Fetch(1, nats.Context(ctx))
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
case ctx.Err() != nil:
|
||||||
|
// Ours ended: the machine is being stopped.
|
||||||
return nil
|
return nil
|
||||||
case errors.Is(err, nats.ErrTimeout):
|
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded), errors.Is(err, nats.ErrTimeout):
|
||||||
|
// **An empty queue, not the end.** A fetch on a context without a deadline waits the
|
||||||
|
// client's own while and then says the deadline passed — the client's, not ours. Read
|
||||||
|
// as "stop", every idle build machine exited clean every half minute and was started
|
||||||
|
// again by its supervisor, which looked like a crash loop with nothing in the log to
|
||||||
|
// say why (2026-10-03, the first build agents). Asked again.
|
||||||
continue
|
continue
|
||||||
case err != nil:
|
case err != nil:
|
||||||
if sub.IsValid() {
|
if sub.IsValid() {
|
||||||
|
|||||||
@@ -169,10 +169,9 @@ func (g *Generator) Graph() Graph { return g.graph }
|
|||||||
//
|
//
|
||||||
// - No floor: no header, no localhost, no `127.0.1.1` — those are the machine's, above the region.
|
// - No floor: no header, no localhost, no `127.0.1.1` — those are the machine's, above the region.
|
||||||
// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback.
|
// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback.
|
||||||
// - `.Names` is every name the mesh serves (issue 111), so anything on the machine reaching a
|
// - `.Names` is the machines (novox/hq ADR 0191): a route's internal name is under its node's
|
||||||
// routed name through its resolver finds the machine serving it; machines with no address yet
|
// internal domain and the resolver answers it by wildcard, and a public name is public DNS's.
|
||||||
// are already left out of the set. A routed name is one alias, itself — a machine has a bare
|
// Machines with no address yet are already left out of the set.
|
||||||
// name beside its full one, a routed name has nothing beside it (issue 157).
|
|
||||||
const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" +
|
const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" +
|
||||||
"{{range .Names}}{{.Address}}\t{{.FQDN}}{{if ne .Name .FQDN}}\t{{.Name}}{{end}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}"
|
"{{range .Names}}{{.Address}}\t{{.FQDN}}{{if ne .Name .FQDN}}\t{{.Name}}{{end}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user