Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d075c63ddb |
+12
-23
@@ -148,34 +148,20 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
// it either finishes or fails is indistinguishable from one that never arrived — which cost a long
|
// it either finishes or fails is indistinguishable from one that never arrived — which cost a long
|
||||||
// diagnosis against a running mesh, chasing "the handler never fired" when the truth was only that
|
// diagnosis against a running mesh, chasing "the handler never fired" when the truth was only that
|
||||||
// the handler said nothing until the end.
|
// the handler said nothing until the end.
|
||||||
fmt.Fprintf(os.Stderr, "a build request arrived for %s (%s)\n", request.Repository, request.ID)
|
fmt.Fprintf(os.Stderr, "a build request arrived for %s\n", request.Repository)
|
||||||
|
|
||||||
// **Everything a build says goes two ways**: to stderr, as always, and onto the bus as the
|
|
||||||
// role's own events under the build's id (novox/hq ADR 0157) — so whoever asked, and anybody
|
|
||||||
// watching, reads the same lines this container's log holds, live, and after the fact from the
|
|
||||||
// stream. Said first, before anything runs, so a build that hangs is one that visibly started.
|
|
||||||
say := func(step, message string) {
|
|
||||||
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
|
||||||
work.Say(step, message)
|
|
||||||
}
|
|
||||||
builder.Said = say
|
|
||||||
defer func() { builder.Said = nil }()
|
|
||||||
if err := work.Began(ctx); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "cannot say a build started: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := link.BuildResult{
|
result := link.BuildResult{
|
||||||
ID: request.ID, Repository: request.Repository, Path: request.Path,
|
ID: request.ID, Repository: request.Repository, Path: request.Path,
|
||||||
Ref: request.Ref, On: on, Source: request.Source,
|
Ref: request.Ref, On: on,
|
||||||
}
|
}
|
||||||
what := "building " + request.Repository
|
fmt.Fprintf(os.Stderr, "building %s", request.Repository)
|
||||||
if request.Path != "" {
|
if request.Path != "" {
|
||||||
what += " at " + request.Path
|
fmt.Fprintf(os.Stderr, " at %s", request.Path)
|
||||||
}
|
}
|
||||||
if request.Ref != "" {
|
if request.Ref != "" {
|
||||||
what += " on " + request.Ref
|
fmt.Fprintf(os.Stderr, " at %s", request.Ref)
|
||||||
}
|
}
|
||||||
say("build", what)
|
fmt.Fprintln(os.Stderr)
|
||||||
|
|
||||||
npmrc, err := packagesFrom()
|
npmrc, err := packagesFrom()
|
||||||
var built builder.Result
|
var built builder.Result
|
||||||
@@ -185,13 +171,16 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
// after a clone that then fails at npm ci.
|
// after a clone that then fails at npm ci.
|
||||||
built, err = builder.Build(ctx, builder.Command, publisher,
|
built, err = builder.Build(ctx, builder.Command, publisher,
|
||||||
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
||||||
forgeFrom(), say, request.Seats)
|
forgeFrom(),
|
||||||
|
func(step, message string) {
|
||||||
|
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A failure is a result. A build that fails and says nothing is indistinguishable from a
|
// A failure is a result. A build that fails and says nothing is indistinguishable from a
|
||||||
// builder that is not running, and those want completely different responses.
|
// builder that is not running, and those want completely different responses.
|
||||||
result.Failed = err.Error()
|
result.Failed = err.Error()
|
||||||
say("failed", err.Error())
|
fmt.Fprintf(os.Stderr, " failed: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
manifest, marshalErr := json.Marshal(built.Manifest)
|
manifest, marshalErr := json.Marshal(built.Manifest)
|
||||||
if marshalErr != nil {
|
if marshalErr != nil {
|
||||||
@@ -208,7 +197,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
for _, r := range built.Read {
|
for _, r := range built.Read {
|
||||||
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||||
}
|
}
|
||||||
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,6 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
// **Before the new assignment can unsettle a seat somebody holds only by being alone**
|
|
||||||
// (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the
|
|
||||||
// assignment resolves against a record rather than against a coincidence.
|
|
||||||
settled, err := recordDerivedHolders(ctx, open)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
fresh, err := open.inventory.Assign(ctx, node, module)
|
fresh, err := open.inventory.Assign(ctx, node, module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -64,9 +57,6 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
|||||||
node, module), nil
|
node, module), nil
|
||||||
}
|
}
|
||||||
said := fmt.Sprintf("%s is assigned %s", node, module)
|
said := fmt.Sprintf("%s is assigned %s", node, module)
|
||||||
for _, line := range settled {
|
|
||||||
said += "\n " + line
|
|
||||||
}
|
|
||||||
plan, _, err := planFor(ctx, open, node)
|
plan, _, err := planFor(ctx, open, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
||||||
|
|||||||
@@ -91,10 +91,6 @@ func reportsReaching(t *testing.T, open *stores, reachable []link.Reach, held ..
|
|||||||
if _, err := (link.Enrolment{Inventory: open.inventory}).Heard(ctx, link.Report{
|
if _, err := (link.Enrolment{Inventory: open.inventory}).Heard(ctx, link.Report{
|
||||||
Node: "anchor", Applied: []string{"hello-web.x"}, Declared: digestOf(body),
|
Node: "anchor", Applied: []string{"hello-web.x"}, Declared: digestOf(body),
|
||||||
Firewall: "ufw", Held: held, Reachable: reachable,
|
Firewall: "ufw", Held: held, Reachable: reachable,
|
||||||
// A machine says which of its links face outside on every apply (novox/hq ADR 0140), and a
|
|
||||||
// filter is not sent to one that has not. The anchor reports one, as a real host does; this
|
|
||||||
// fixture lacked it from 2026-09-28 and nothing ran the test (issue 177).
|
|
||||||
Outward: []string{"eth0"},
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -109,10 +105,10 @@ var (
|
|||||||
|
|
||||||
func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) {
|
func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
if _, err := take(t.Context(), open, "anchor", "nftables", takeOptions{Yes: true}); !errors.Is(err, inventory.ErrNotAssigned) {
|
if _, err := take(t.Context(), open, "anchor", "nftables"); !errors.Is(err, inventory.ErrNotAssigned) {
|
||||||
t.Fatalf("taking an unassigned module gave %v", err)
|
t.Fatalf("taking an unassigned module gave %v", err)
|
||||||
}
|
}
|
||||||
if _, err := take(t.Context(), open, "laptop", "network", takeOptions{Yes: true}); !errors.Is(err, inventory.ErrNotAdopted) {
|
if _, err := take(t.Context(), open, "laptop", "network"); !errors.Is(err, inventory.ErrNotAdopted) {
|
||||||
t.Fatalf("taking on a converged node gave %v", err)
|
t.Fatalf("taking on a converged node gave %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +139,7 @@ func TestTheFlipIsRefusedWhileAFoundContainerIsHeld(t *testing.T) {
|
|||||||
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
reportsHolding(t, open, heldContainer, heldFile)
|
reportsHolding(t, open, heldContainer, heldFile)
|
||||||
said, err := take(t.Context(), open, "anchor", "hello-web", takeOptions{Yes: true})
|
said, err := take(t.Context(), open, "anchor", "hello-web")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +152,7 @@ func TestTakingNamesWhatItReplaces(t *testing.T) {
|
|||||||
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
|
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -330,7 +326,7 @@ func digestIn(t *testing.T, preview string) string {
|
|||||||
func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -396,7 +392,7 @@ func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
|||||||
func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -441,7 +437,7 @@ func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
|||||||
func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
since := time.Now()
|
since := time.Now()
|
||||||
@@ -484,7 +480,7 @@ func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
|||||||
func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Only a loopback listener: nothing off the machine, which is the same silence.
|
// Only a loopback listener: nothing off the machine, which is the same silence.
|
||||||
@@ -512,7 +508,7 @@ func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
|||||||
func TestAssigningWaitsForWhateverIsConvergingTheNode(t *testing.T) {
|
func TestAssigningWaitsForWhateverIsConvergingTheNode(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
|
|||||||
+17
-206
@@ -24,11 +24,6 @@ import (
|
|||||||
func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node) error {
|
func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node) error {
|
||||||
if !node.Adopted {
|
if !node.Adopted {
|
||||||
fmt.Printf(" mode converged\n")
|
fmt.Printf(" mode converged\n")
|
||||||
// A converged machine holds nothing, and can still run what nobody asked for
|
|
||||||
// (novox/hq ADR 0163): what it reports as strays is said whatever its mode.
|
|
||||||
if said, err := inv.AdoptionOf(ctx, node.Name); err == nil && len(said.Strays) > 0 {
|
|
||||||
showStrays(said.Strays)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf(" mode adopted since %s\n",
|
fmt.Printf(" mode adopted since %s\n",
|
||||||
@@ -67,26 +62,11 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node
|
|||||||
if h.Kept != "" {
|
if h.Kept != "" {
|
||||||
fmt.Printf(" %-17s original kept at %s\n", "", h.Kept)
|
fmt.Printf(" %-17s original kept at %s\n", "", h.Kept)
|
||||||
}
|
}
|
||||||
for _, f := range comparisonLines(h) {
|
|
||||||
fmt.Printf(" %-17s %s\n", "", f)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
showStrays(said.Strays)
|
|
||||||
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// showStrays says what a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
|
||||||
func showStrays(strays []inventory.Stray) {
|
|
||||||
if len(strays) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf(" strays %d container(s) the mesh neither wrote nor holds:\n", len(strays))
|
|
||||||
for _, s := range strays {
|
|
||||||
fmt.Printf(" %-17s %s (%s)\n", "", s.Name, s.Detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// showTunnel is the node show lines about the tunnel an adopted node found and carried (novox/hq
|
// showTunnel is the node show lines about the tunnel an adopted node found and carried (novox/hq
|
||||||
// ADR 0105): what it presented at enrolment, and what it last said about taking it over.
|
// ADR 0105): what it presented at enrolment, and what it last said about taking it over.
|
||||||
func showTunnel(ctx context.Context, inv *inventory.Inventory, name string) error {
|
func showTunnel(ctx context.Context, inv *inventory.Inventory, name string) error {
|
||||||
@@ -156,14 +136,7 @@ const DefaultFilter = "nftables"
|
|||||||
// take is a module's cutover on an adopted node: the operator's act, done when that module's data
|
// take is a module's cutover on an adopted node: the operator's act, done when that module's data
|
||||||
// has moved. From the next push its resources converge there like any other, replacing what the
|
// has moved. From the next push its resources converge there like any other, replacing what the
|
||||||
// node found and holds for it.
|
// node found and holds for it.
|
||||||
// takeOptions is what a take was told about the differences it may pass (novox/hq ADR 0163).
|
func take(ctx context.Context, open *stores, node, module string) (string, error) {
|
||||||
type takeOptions struct {
|
|
||||||
Yes bool
|
|
||||||
Downgrade bool
|
|
||||||
Replace map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func take(ctx context.Context, open *stores, node, module string, opts takeOptions) (string, error) {
|
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
assigned, err := inv.Assigned(ctx, node)
|
assigned, err := inv.Assigned(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -177,170 +150,27 @@ func take(ctx context.Context, open *stores, node, module string, opts takeOptio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The comparison first (novox/hq ADR 0163): every held thing the module would replace, beside
|
|
||||||
// what the module declares, and the differences that refuse unless named.
|
|
||||||
reported, err := inv.AdoptionOf(ctx, node)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
preview, refusals := comparisonOf(reported.Held, module, opts)
|
|
||||||
if len(refusals) > 0 {
|
|
||||||
return "", fmt.Errorf("taking %s on %s is refused:\n %s\n%s", module, node,
|
|
||||||
strings.Join(refusals, "\n "), preview)
|
|
||||||
}
|
|
||||||
if !opts.Yes {
|
|
||||||
return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes` cuts it over as previewed", node, module), nil
|
|
||||||
}
|
|
||||||
if err := inv.Take(ctx, node, module); err != nil {
|
if err := inv.Take(ctx, node, module); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
said := fmt.Sprintf("%s is taken on %s", module, node)
|
said := fmt.Sprintf("%s is taken on %s", module, node)
|
||||||
if preview != "" {
|
reported, err := inv.AdoptionOf(ctx, node)
|
||||||
said += "; the next push replaces what the node found and holds for it:\n" + preview
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var replaces []string
|
||||||
|
for _, h := range reported.Held {
|
||||||
|
if h.Module == module {
|
||||||
|
replaces = append(replaces, " "+heldLine(h))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(replaces) > 0 {
|
||||||
|
said += "; the next push replaces what the node found and holds for it:\n" +
|
||||||
|
strings.Join(replaces, "\n")
|
||||||
}
|
}
|
||||||
return said + fmt.Sprintf("\n run `push %s` to cut it over", node), nil
|
return said + fmt.Sprintf("\n run `push %s` to cut it over", node), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// comparisonOf is a take's preview: for every held thing of the module, what runs beside what the
|
|
||||||
// module declares, and the refusals the differences earn unless the take named them
|
|
||||||
// (novox/hq ADR 0163): an image older than the one running, a declared file that differs from the
|
|
||||||
// found one. A narrowed port and a shared network are said and not refused.
|
|
||||||
func comparisonOf(held []inventory.Held, module string, opts takeOptions) (string, []string) {
|
|
||||||
var b strings.Builder
|
|
||||||
var refusals []string
|
|
||||||
for _, h := range held {
|
|
||||||
if h.Module != module {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, " %s", heldLine(h))
|
|
||||||
if h.Kept != "" {
|
|
||||||
fmt.Fprintf(&b, ", original kept at %s", h.Kept)
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
for _, line := range comparisonLines(h) {
|
|
||||||
fmt.Fprintf(&b, " %s\n", line)
|
|
||||||
}
|
|
||||||
f := factsOf(h)
|
|
||||||
if f.downgrade && !opts.Downgrade {
|
|
||||||
refusals = append(refusals, fmt.Sprintf("%s: the module's image (%s, made %s) is older than the one running (%s, made %s) — "+
|
|
||||||
"a service that migrated its data forward may not start on it; `--downgrade` to take it anyway",
|
|
||||||
h.Target, f.declaredImage, day(f.declaredCreated), f.image, day(f.imageCreated)))
|
|
||||||
}
|
|
||||||
if f.differs && !opts.Replace[h.Target] && !opts.Replace["*"] {
|
|
||||||
refusals = append(refusals, fmt.Sprintf("%s: the module's content differs from the file found; the lines above "+
|
|
||||||
"marked - are lost by taking it; `--replace %s` to replace it anyway, or declare the file partially",
|
|
||||||
h.Target, h.Target))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String(), refusals
|
|
||||||
}
|
|
||||||
|
|
||||||
// facts is a held thing's facts as the preview reads them.
|
|
||||||
type facts struct {
|
|
||||||
image, imageCreated, declaredImage, declaredCreated string
|
|
||||||
downgrade, differs bool
|
|
||||||
networks map[string][]string
|
|
||||||
mounts, ports, declaredPorts, declaredVolumes []string
|
|
||||||
difference []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func factsOf(h inventory.Held) facts {
|
|
||||||
var f facts
|
|
||||||
if h.Facts == nil {
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
str := func(k string) string { s, _ := h.Facts[k].(string); return s }
|
|
||||||
list := func(k string) []string {
|
|
||||||
var out []string
|
|
||||||
if raw, ok := h.Facts[k].([]any); ok {
|
|
||||||
for _, x := range raw {
|
|
||||||
if s, ok := x.(string); ok {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
f.image, f.imageCreated = str("image"), str("image_created")
|
|
||||||
f.declaredImage, f.declaredCreated = str("declared_image"), str("declared_image_created")
|
|
||||||
f.downgrade, _ = h.Facts["downgrade"].(bool)
|
|
||||||
f.differs, _ = h.Facts["differs"].(bool)
|
|
||||||
f.mounts, f.ports = list("mounts"), list("ports")
|
|
||||||
f.declaredPorts, f.declaredVolumes, f.difference = list("declared_ports"), list("declared_volumes"), list("difference")
|
|
||||||
if raw, ok := h.Facts["networks"].(map[string]any); ok {
|
|
||||||
f.networks = map[string][]string{}
|
|
||||||
for name, members := range raw {
|
|
||||||
var out []string
|
|
||||||
if ms, ok := members.([]any); ok {
|
|
||||||
for _, m := range ms {
|
|
||||||
if s, ok := m.(string); ok {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f.networks[name] = out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
// comparisonLines says a held thing's facts the way a person weighs them.
|
|
||||||
func comparisonLines(h inventory.Held) []string {
|
|
||||||
f := factsOf(h)
|
|
||||||
var out []string
|
|
||||||
if f.image != "" || f.declaredImage != "" {
|
|
||||||
line := fmt.Sprintf("runs %s", orNone(f.image))
|
|
||||||
if f.imageCreated != "" {
|
|
||||||
line += " (made " + day(f.imageCreated) + ")"
|
|
||||||
}
|
|
||||||
line += "; the module declares " + orNone(f.declaredImage)
|
|
||||||
switch {
|
|
||||||
case f.declaredCreated != "":
|
|
||||||
line += " (made " + day(f.declaredCreated) + ")"
|
|
||||||
case f.declaredImage != "":
|
|
||||||
line += " (not on the machine yet, so its age is unknown)"
|
|
||||||
}
|
|
||||||
if f.downgrade {
|
|
||||||
line += " — DOWNGRADE"
|
|
||||||
}
|
|
||||||
out = append(out, line)
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(f.networks))
|
|
||||||
for n := range f.networks {
|
|
||||||
names = append(names, n)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
for _, n := range names {
|
|
||||||
if members := f.networks[n]; len(members) > 0 {
|
|
||||||
out = append(out, fmt.Sprintf("on the network %s with %s, which may reach it by name and will not once it moves to the module's own network",
|
|
||||||
n, strings.Join(members, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(f.ports) > 0 || len(f.declaredPorts) > 0 {
|
|
||||||
out = append(out, fmt.Sprintf("publishes %s; the module declares %s",
|
|
||||||
orNone(strings.Join(f.ports, " ")), orNone(strings.Join(f.declaredPorts, " "))))
|
|
||||||
}
|
|
||||||
if len(f.mounts) > 0 || len(f.declaredVolumes) > 0 {
|
|
||||||
out = append(out, fmt.Sprintf("mounts %s; the module declares %s",
|
|
||||||
orNone(strings.Join(f.mounts, " ")), orNone(strings.Join(f.declaredVolumes, " "))))
|
|
||||||
}
|
|
||||||
if f.differs {
|
|
||||||
out = append(out, "the declared content differs from the file found (- lost, + new):")
|
|
||||||
for _, d := range f.difference {
|
|
||||||
out = append(out, " "+d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// day is a timestamp as a person reads it in a preview: its date.
|
|
||||||
func day(stamp string) string {
|
|
||||||
if len(stamp) >= 10 {
|
|
||||||
return stamp[:10]
|
|
||||||
}
|
|
||||||
return stamp
|
|
||||||
}
|
|
||||||
|
|
||||||
// reportFreshFor is how old a node's account of itself may be for the flip to act on it. A
|
// reportFreshFor is how old a node's account of itself may be for the flip to act on it. A
|
||||||
// variable so a test can age a report without waiting.
|
// variable so a test can age a report without waiting.
|
||||||
var reportFreshFor = 15 * time.Minute
|
var reportFreshFor = 15 * time.Minute
|
||||||
@@ -766,31 +596,12 @@ func adopt(ctx context.Context, open *stores, node string) (string, error) {
|
|||||||
|
|
||||||
// takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above.
|
// takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above.
|
||||||
func takeCommand(ctx context.Context, args []string) error {
|
func takeCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("take", flag.ContinueOnError)
|
if len(args) != 2 {
|
||||||
yes := set.Bool("yes", false, "cut over as previewed; without it the comparison is printed and nothing is taken")
|
return errors.New("take <node> <module>")
|
||||||
downgrade := set.Bool("downgrade", false, "take it although the module's image is older than the one running")
|
|
||||||
var replace stringList
|
|
||||||
set.Var(&replace, "replace", "a found file's path whose content the module may replace although it differs (repeatable; * for every one)")
|
|
||||||
positionals, err := parseAround(set, args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if len(positionals) != 2 {
|
return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, args[0], args[1]) })
|
||||||
return errors.New("take <node> <module> [--yes] [--downgrade] [--replace <path>]...")
|
|
||||||
}
|
|
||||||
opts := takeOptions{Yes: *yes, Downgrade: *downgrade, Replace: map[string]bool{}}
|
|
||||||
for _, r := range replace {
|
|
||||||
opts.Replace[r] = true
|
|
||||||
}
|
|
||||||
return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, positionals[0], positionals[1], opts) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// stringList is a repeatable flag.
|
|
||||||
type stringList []string
|
|
||||||
|
|
||||||
func (l *stringList) String() string { return strings.Join(*l, ",") }
|
|
||||||
func (l *stringList) Set(v string) error { *l = append(*l, v); return nil }
|
|
||||||
|
|
||||||
func convergeCommand(ctx context.Context, args []string) error {
|
func convergeCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("converge", flag.ContinueOnError)
|
set := flag.NewFlagSet("converge", flag.ContinueOnError)
|
||||||
yes := set.String("yes", "", "do it, naming the digest the preview printed; without it, only "+
|
yes := set.String("yes", "", "do it, naming the digest the preview printed; without it, only "+
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func commands(who Authenticator) http.Handler {
|
|||||||
}))
|
}))
|
||||||
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
||||||
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return take(ctx, open, in.Node, in.Module, takeOptions{Yes: true})
|
return take(ctx, open, in.Node, in.Module)
|
||||||
}))
|
}))
|
||||||
mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter)
|
return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter)
|
||||||
|
|||||||
+33
-138
@@ -10,8 +10,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
@@ -177,14 +175,10 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
|||||||
func buildsCommand(ctx context.Context, args []string) error {
|
func buildsCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("builds", flag.ContinueOnError)
|
set := flag.NewFlagSet("builds", flag.ContinueOnError)
|
||||||
limit := set.Int("n", 20, "how many to show")
|
limit := set.Int("n", 20, "how many to show")
|
||||||
logOf := set.String("log", "", "a build's id: print what the build machine said, line by line")
|
|
||||||
positionals, err := parseAround(set, args)
|
positionals, err := parseAround(set, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if *logOf != "" {
|
|
||||||
return buildLog(ctx, *logOf)
|
|
||||||
}
|
|
||||||
module := ""
|
module := ""
|
||||||
if len(positionals) == 1 {
|
if len(positionals) == 1 {
|
||||||
module = positionals[0]
|
module = positionals[0]
|
||||||
@@ -225,8 +219,8 @@ func buildsCommand(ctx context.Context, args []string) error {
|
|||||||
if !b.Worked() {
|
if !b.Worked() {
|
||||||
outcome = "failed"
|
outcome = "failed"
|
||||||
}
|
}
|
||||||
fmt.Printf("%-18s %-14s %-10s %s %s\n",
|
fmt.Printf("%-18s %-14s %-10s %s\n",
|
||||||
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"), b.ID)
|
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"))
|
||||||
fmt.Printf(" %s", b.Repository)
|
fmt.Printf(" %s", b.Repository)
|
||||||
if b.Ref != "" {
|
if b.Ref != "" {
|
||||||
fmt.Printf(" at %s", b.Ref)
|
fmt.Printf(" at %s", b.Ref)
|
||||||
@@ -414,14 +408,11 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
|||||||
Path: path,
|
Path: path,
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
Held: heldBy(ctx),
|
Held: heldBy(ctx),
|
||||||
Seats: seatBases(ctx),
|
|
||||||
}
|
}
|
||||||
fmt.Printf("asked for %s", source)
|
fmt.Printf("asked for %s", source)
|
||||||
if source.Seat != "" {
|
if source.Seat != "" {
|
||||||
fmt.Printf(" (%s)", repository)
|
fmt.Printf(" (%s)", repository)
|
||||||
}
|
}
|
||||||
// The id is how a person follows this build while it runs: `builds --log <id>`.
|
|
||||||
fmt.Printf(" as %s", request.ID)
|
|
||||||
if path != "" {
|
if path != "" {
|
||||||
fmt.Printf(" at %s", path)
|
fmt.Printf(" at %s", path)
|
||||||
}
|
}
|
||||||
@@ -436,104 +427,64 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
|||||||
}
|
}
|
||||||
defer ask.Close()
|
defer ask.Close()
|
||||||
|
|
||||||
if wait == 0 {
|
|
||||||
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
|
|
||||||
// controller takes it in — records the build, registers the module — whether or not anybody
|
|
||||||
// is still here. A tool call cannot hold a connection for the minutes a build takes; it
|
|
||||||
// follows the build by its id instead.
|
|
||||||
if err := ask.Ask(ctx, request); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("asked, not waited for: `builds --log %s` follows it as it runs, and `builds` "+
|
|
||||||
"shows what came of it; the module is registered when the outcome comes\n", request.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := ask.Submit(ctx, request, wait)
|
result, err := ask.Submit(ctx, request, wait)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one
|
||||||
|
// nobody asked for, and the difference is the whole of whether somebody should be looking at
|
||||||
|
// something.
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer open.Close()
|
defer open.Close()
|
||||||
manifest, kept, err := takeIn(ctx, open.inventory, result)
|
inv := open.inventory
|
||||||
if err != nil {
|
kept := buildFrom(result)
|
||||||
|
if err := inv.RecordBuild(ctx, kept); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if result.Failed != "" {
|
||||||
|
// The builder's own words. Wrapping them in something about the control plane would put
|
||||||
|
// two explanations between a person and a build log.
|
||||||
|
return fmt.Errorf("%s could not build %s:\n%s", result.On, result.Repository, result.Failed)
|
||||||
|
}
|
||||||
|
|
||||||
// Said as recorded: what each artifact is, not where this builder happened to push it.
|
// Said as recorded: what each artifact is, not where this builder happened to push it.
|
||||||
for _, made := range kept.Made {
|
for _, made := range kept.Made {
|
||||||
fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference)
|
fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference)
|
||||||
}
|
}
|
||||||
fmt.Printf("\n%s %s, built on %s from %s\n",
|
|
||||||
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
|
||||||
saysWhenThePolicyActs(ctx, open.inventory, manifest.Module)
|
|
||||||
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// saysWhenThePolicyActs tells whoever built a module that its upgrade policy will send the
|
// Parsed with the same parser a hand-written manifest goes through. A second path would be a
|
||||||
// result on at once (novox/hq issue 126, ADR 0163): a person choreographing a data move must
|
// second thing to disagree about what a manifest is. The manifest as recorded, so the catalogue
|
||||||
// know which module will not wait for them.
|
// holds references by digest and path and every declaration composes the store's address in.
|
||||||
func saysWhenThePolicyActs(ctx context.Context, inv *inventory.Inventory, module string) {
|
|
||||||
if u, err := inv.UpgradeOf(ctx, module); err == nil && u.RollOut {
|
|
||||||
how := "one machine at a time"
|
|
||||||
if u.Together {
|
|
||||||
how = "every machine at once"
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s rolls out on build: the machines running it are sent this now, %s — "+
|
|
||||||
"`upgrade %s record` first if something must move before it does\n", module, how, module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and
|
|
||||||
// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's
|
|
||||||
// result reaches the catalogue whether or not the asker was still listening.
|
|
||||||
//
|
|
||||||
// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one
|
|
||||||
// nobody asked for, and the difference is the whole of whether somebody should be looking at
|
|
||||||
// something. Then parsed with the same parser a hand-written manifest goes through — a second path
|
|
||||||
// would be a second thing to disagree about what a manifest is — and registered with where it came
|
|
||||||
// from: **for a source on a seat, as the path and the seat, never the URL just cloned** (ADR 0111),
|
|
||||||
// which the request carried and the outcome echoes. A definition naming an installation is refused
|
|
||||||
// here, where it would enter the catalogue; the build stays recorded and the refusal says which.
|
|
||||||
//
|
|
||||||
// Idempotent: the same outcome taken in twice registers the same module twice, which is one row
|
|
||||||
// written with the same values.
|
|
||||||
func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) (
|
|
||||||
catalogue.Manifest, inventory.Build, error) {
|
|
||||||
kept := buildFrom(result)
|
|
||||||
if err := inv.RecordBuild(ctx, kept); err != nil {
|
|
||||||
return catalogue.Manifest{}, kept, err
|
|
||||||
}
|
|
||||||
if result.Failed != "" {
|
|
||||||
// The builder's own words. Wrapping them in something about the control plane would put
|
|
||||||
// two explanations between a person and a build log.
|
|
||||||
return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s",
|
|
||||||
result.On, result.Repository, result.Failed)
|
|
||||||
}
|
|
||||||
manifest, err := catalogue.ParseManifest(kept.Manifest)
|
manifest, err := catalogue.ParseManifest(kept.Manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
return fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
||||||
result.On, result.Repository, err)
|
result.On, result.Repository, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recorded with where it came from, so "is this current?" is answerable without building it
|
||||||
|
// again (novox/hq ADR 0009). **For a source on a seat, as the path and the seat, never the URL
|
||||||
|
// just cloned** (ADR 0111): the URL is where the forge runs today, and recording it would put
|
||||||
|
// the forge's address back into every module built from it. The build log above keeps the URL,
|
||||||
|
// because that is what was cloned.
|
||||||
recorded := inventory.Source{
|
recorded := inventory.Source{
|
||||||
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
||||||
BuiltFrom: result.Commit, Head: result.Commit,
|
BuiltFrom: result.Commit, Head: result.Commit,
|
||||||
}
|
}
|
||||||
if result.Source != nil && result.Source.Seat != "" {
|
if source.Seat != "" {
|
||||||
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
|
recorded.Repository, recorded.Seat = source.Repository, source.Seat
|
||||||
}
|
|
||||||
if err := namesNoInstallation(manifest); err != nil {
|
|
||||||
return manifest, kept, fmt.Errorf("%s built %s (%s), and the mesh does not register it: %w",
|
|
||||||
result.On, result.Repository, short(result.Commit), err)
|
|
||||||
}
|
}
|
||||||
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
|
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
|
||||||
return manifest, kept, err
|
return err
|
||||||
}
|
}
|
||||||
return manifest, kept, nil
|
fmt.Printf("\n%s %s, built on %s from %s\n",
|
||||||
|
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||||
|
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAndShow builds and prints the manifest without recording anything.
|
// buildAndShow builds and prints the manifest without recording anything.
|
||||||
@@ -562,7 +513,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
|
|||||||
result, err := ask.Submit(ctx, link.BuildRequest{
|
result, err := ask.Submit(ctx, link.BuildRequest{
|
||||||
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
|
||||||
Repository: repository, Path: path, Ref: ref,
|
Repository: repository, Path: path, Ref: ref,
|
||||||
Held: heldBy(ctx), Seats: seatBases(ctx),
|
Held: heldBy(ctx),
|
||||||
}, wait)
|
}, wait)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -596,8 +547,6 @@ type answers struct {
|
|||||||
// pair that answers "has it caught up", which waiting alone cannot (the sent digest is
|
// pair that answers "has it caught up", which waiting alone cannot (the sent digest is
|
||||||
// recorded at send, not at apply).
|
// recorded at send, not at apply).
|
||||||
reported []inventory.Reported
|
reported []inventory.Reported
|
||||||
// plans is what the last merges produced and where each stands (novox/hq ADR 0162).
|
|
||||||
plans []inventory.Plan
|
|
||||||
// refused is why a machine cannot be worked out at all, by name. A different thing from every
|
// refused is why a machine cannot be worked out at all, by name. A different thing from every
|
||||||
// other answer here: those are about a machine that was told something, and this is about one
|
// other answer here: those are about a machine that was told something, and this is about one
|
||||||
// that cannot be told anything — it never reaches waiting, because nothing was computed for it
|
// that cannot be told anything — it never reaches waiting, because nothing was computed for it
|
||||||
@@ -669,57 +618,3 @@ func askOver(_ *link.Server) (link.Builders, error) {
|
|||||||
}
|
}
|
||||||
return link.BuildsOverNATS(address)
|
return link.BuildsOverNATS(address)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildLog prints everything a build machine said about one build, read back from the bus.
|
|
||||||
//
|
|
||||||
// **From the stream, not from a record** (novox/hq ADR 0157). A build's lines are the role's own
|
|
||||||
// events under the build's id, retained with every other event; the mesh keeps no second copy. Read
|
|
||||||
// with a consumer of its own that is gone when this returns, so nothing accumulates in the server
|
|
||||||
// for the reading, and filtered by subject, so one build's lines are all that travel.
|
|
||||||
func buildLog(ctx context.Context, id string) error {
|
|
||||||
address, err := broker.BusAddress()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
js, err := broker.Dial(address)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot reach the bus to read a build's log: %w", err)
|
|
||||||
}
|
|
||||||
defer js.Close()
|
|
||||||
|
|
||||||
sub, err := js.Context().PullSubscribe(link.BuildLog(id), "",
|
|
||||||
nats.BindStream(broker.EventsStream), nats.DeliverAll(), nats.AckNone())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot read %s from the bus: %w", link.BuildLog(id), err)
|
|
||||||
}
|
|
||||||
defer func() { _ = sub.Unsubscribe() }()
|
|
||||||
|
|
||||||
printed := 0
|
|
||||||
for {
|
|
||||||
batch, err := sub.Fetch(200, nats.MaxWait(2*time.Second))
|
|
||||||
if err != nil && !errors.Is(err, nats.ErrTimeout) && !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
return fmt.Errorf("reading a build's log: %w", err)
|
|
||||||
}
|
|
||||||
for _, msg := range batch {
|
|
||||||
var line link.BuildLine
|
|
||||||
if err := json.Unmarshal(msg.Data, &line); err != nil {
|
|
||||||
fmt.Printf(" ? %s\n", string(msg.Data))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
at := line.At
|
|
||||||
if t, err := time.Parse(time.RFC3339Nano, line.At); err == nil {
|
|
||||||
at = t.Local().Format("15:04:05")
|
|
||||||
}
|
|
||||||
fmt.Printf("%s %4d [%s] %s\n", at, line.Seq, line.Step, line.Message)
|
|
||||||
printed++
|
|
||||||
}
|
|
||||||
if len(batch) < 200 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if printed == 0 {
|
|
||||||
fmt.Printf("nothing on the bus for build %s: no build by that id in the last week, or a build "+
|
|
||||||
"machine older than this that said nothing while building\n", id)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A build's outcome is taken in the same way whoever hears it (novox/hq issue 176): recorded, and
|
|
||||||
// the module registered with its source as the seat and path when the request said so — never the
|
|
||||||
// URL. A definition naming an installation is recorded and not registered; a failure is recorded
|
|
||||||
// and said.
|
|
||||||
func TestABuildHeardIsRecordedAndRegistered(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
manifest, _ := json.Marshal(map[string]any{"module": "shop", "version": "3"})
|
|
||||||
m, _, err := takeIn(ctx, open.inventory, link.BuildResult{
|
|
||||||
ID: "b-1", Repository: "http://forge.internal:20000/novox/shop.git", Path: "modules/shop",
|
|
||||||
Ref: "main", On: "anchor", Commit: "abcdef0123", Manifest: manifest,
|
|
||||||
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/shop"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if m.Module != "shop" {
|
|
||||||
t.Fatalf("registered %q", m.Module)
|
|
||||||
}
|
|
||||||
shelf, err := open.inventory.Catalogue(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, held := shelf["shop"]; !held {
|
|
||||||
t.Fatal("the module a heard build produced is not in the catalogue")
|
|
||||||
}
|
|
||||||
src, err := open.inventory.SourceOf(ctx, "shop")
|
|
||||||
if err != nil || src.Seat != "git" || src.Repository != "novox/shop" || src.BuiltFrom != "abcdef0123" {
|
|
||||||
t.Fatalf("the source is the seat and the path, never the URL: %+v %v", src, err)
|
|
||||||
}
|
|
||||||
builds, err := open.inventory.Builds(ctx, "shop", 5)
|
|
||||||
if err != nil || len(builds) != 1 || builds[0].ID != "b-1" {
|
|
||||||
t.Fatalf("the build is not recorded once: %v %v", builds, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
named, _ := json.Marshal(map[string]any{"module": "idp", "version": "1", "resources": []any{
|
|
||||||
map[string]any{"id": "server", "type": "container", "image": "x@sha256:aa",
|
|
||||||
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}}}})
|
|
||||||
_, _, err = takeIn(ctx, open.inventory, link.BuildResult{
|
|
||||||
ID: "b-2", Repository: "/r", On: "anchor", Commit: "0123456789", Manifest: named})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "does not register it") {
|
|
||||||
t.Fatalf("a definition naming an installation was taken in: %v", err)
|
|
||||||
}
|
|
||||||
if shelf, _ := open.inventory.Catalogue(ctx); shelf["idp"].Module != "" {
|
|
||||||
t.Fatal("the refused module was registered anyway")
|
|
||||||
}
|
|
||||||
if builds, _ := open.inventory.Builds(ctx, "idp", 5); len(builds) != 1 {
|
|
||||||
t.Fatalf("the refused build was not recorded: %v", builds)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, err = takeIn(ctx, open.inventory, link.BuildResult{ID: "b-3", Repository: "/r", On: "anchor", Failed: "no compiler"})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "no compiler") {
|
|
||||||
t.Fatalf("a failure is said in the builder's words: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// moduleCheck judges manifests where they are written, with no mesh (novox/hq ADR 0037, issue 148).
|
|
||||||
//
|
|
||||||
// **The same functions registration runs, and nothing the command line adds** (ADR 0035): the strict
|
|
||||||
// parse with every per-manifest problem, then the rules no single manifest can be judged against,
|
|
||||||
// over exactly the manifests given. Somebody describing their own application in their own
|
|
||||||
// repository runs this before pushing and finds out there, rather than when a running mesh refuses
|
|
||||||
// the registration or, later, when a machine applies something that resolved and should not have.
|
|
||||||
//
|
|
||||||
// **What it cannot know without a store, it says.** The mesh's own seat set is the store's (ADR
|
|
||||||
// 0122); this binary carries a compiled copy that the store overrides when loaded, so a claim on a
|
|
||||||
// mesh seat is judged fully only at registration. A seat another module declares is unknown unless
|
|
||||||
// that module's manifest is passed too. Both are printed as a note, not as a problem — a check that
|
|
||||||
// refused what it could not see would teach people to ignore it.
|
|
||||||
func moduleCheck(paths []string, out io.Writer) error {
|
|
||||||
if len(paths) == 0 {
|
|
||||||
return errors.New("module check <manifest.json>... — one file per module; pass every " +
|
|
||||||
"manifest of a repository together so the rules between them are checked too")
|
|
||||||
}
|
|
||||||
shelf := catalogue.Shelf{}
|
|
||||||
faulted := map[string]bool{}
|
|
||||||
failed := 0
|
|
||||||
for _, path := range paths {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(out, "%s: %v\n", path, err)
|
|
||||||
failed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m, err := catalogue.ParseManifest(raw)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(out, "%s: %v\n", path, err)
|
|
||||||
failed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if first, twice := shelf[m.Module]; twice {
|
|
||||||
_ = first
|
|
||||||
fmt.Fprintf(out, "%s: %s was already given; two manifests name one module\n", path, m.Module)
|
|
||||||
failed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here, in the
|
|
||||||
// catalogue-wide test, and at registration, which refuses in the same words.
|
|
||||||
if named := catalogue.InstallationProblems(m); len(named) > 0 {
|
|
||||||
for _, p := range named {
|
|
||||||
fmt.Fprintf(out, "%s: %s\n", path, p)
|
|
||||||
}
|
|
||||||
failed += len(named)
|
|
||||||
faulted[m.Module] = true
|
|
||||||
}
|
|
||||||
shelf[m.Module] = m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Between the manifests: a seat declared twice, a use of a seat nothing declares, a claim on
|
|
||||||
// a seat that does not exist. Run only over what parsed, because a problem inside one manifest
|
|
||||||
// has already been said and would be said again here in a worse form.
|
|
||||||
problems := catalogue.CatalogueProblems(shelf)
|
|
||||||
sort.Strings(problems)
|
|
||||||
for _, p := range problems {
|
|
||||||
fmt.Fprintln(out, p)
|
|
||||||
}
|
|
||||||
failed += len(problems)
|
|
||||||
|
|
||||||
var names []string
|
|
||||||
for name := range shelf {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
for _, name := range names {
|
|
||||||
m := shelf[name]
|
|
||||||
if faulted[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Fprintf(out, "%s: ok", name)
|
|
||||||
if n := len(m.Tools); n > 0 {
|
|
||||||
fmt.Fprintf(out, ", %d tool(s)", n)
|
|
||||||
}
|
|
||||||
if len(m.Invokes) > 0 {
|
|
||||||
fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes))
|
|
||||||
}
|
|
||||||
fmt.Fprintln(out)
|
|
||||||
}
|
|
||||||
if failed > 0 {
|
|
||||||
return fmt.Errorf("%d problem(s) in %d manifest(s)", failed, len(paths))
|
|
||||||
}
|
|
||||||
fmt.Fprintf(out, "%d manifest(s) checked. Judged against the seats this binary carries; a claim on "+
|
|
||||||
"one of the mesh's own seats is judged fully at registration, and a seat declared by a "+
|
|
||||||
"module not given here reads as unknown\n", len(paths))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func joinInvokes(invokes []string) string {
|
|
||||||
if len(invokes) == 1 && invokes[0] == "*" {
|
|
||||||
return "every tool"
|
|
||||||
}
|
|
||||||
s := ""
|
|
||||||
for i, t := range invokes {
|
|
||||||
if i > 0 {
|
|
||||||
s += ", "
|
|
||||||
}
|
|
||||||
s += t
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// manifestsUnder lists every module.json below a directory, for `module check <dir>`.
|
|
||||||
func manifestsUnder(dir string) ([]string, error) {
|
|
||||||
var found []string
|
|
||||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if d.IsDir() && (d.Name() == "node_modules" || d.Name() == ".git" || d.Name() == "dist") {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
if !d.IsDir() && d.Name() == "module.json" {
|
|
||||||
found = append(found, path)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
sort.Strings(found)
|
|
||||||
return found, err
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The check anybody can run is the check registration runs (novox/hq issue 148, ADR 0037): a manifest
|
|
||||||
// with a known fault is named, and one without passes, with no store opened.
|
|
||||||
func TestModuleCheckNamesAFaultAndNeedsNoMesh(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
good := filepath.Join(dir, "good.json")
|
|
||||||
bad := filepath.Join(dir, "bad.json")
|
|
||||||
os.WriteFile(good, []byte(`{"module":"shop","version":"1","tools":["price"],"invokes":["mesh-catalog.catalog_modules"]}`), 0o600)
|
|
||||||
os.WriteFile(bad, []byte(`{"module":"till","version":"1","invokes":["shop"]}`), 0o600)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
if err := moduleCheck([]string{good}, &out); err != nil {
|
|
||||||
t.Fatalf("a sound manifest was refused: %v\n%s", err, out.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(out.String(), "shop: ok, 1 tool(s), invokes mesh-catalog.catalog_modules") {
|
|
||||||
t.Fatalf("the report does not say what it checked:\n%s", out.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
out.Reset()
|
|
||||||
err := moduleCheck([]string{good, bad}, &out)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a manifest invoking a module and no tool passed")
|
|
||||||
}
|
|
||||||
if !strings.Contains(out.String(), `till invokes "shop", which does not name a tool`) {
|
|
||||||
t.Fatalf("the fault is not named in the manifest's words:\n%s", out.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The rules between manifests run over what was given together: a seat two modules declare is
|
|
||||||
// refused, which no single-manifest check can see.
|
|
||||||
func TestModuleCheckJudgesBetweenTheManifestsGiven(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
a := filepath.Join(dir, "a.json")
|
|
||||||
b := filepath.Join(dir, "b.json")
|
|
||||||
os.WriteFile(a, []byte(`{"module":"a","version":"1","seats":[{"name":"printer","scope":"mesh"}]}`), 0o600)
|
|
||||||
os.WriteFile(b, []byte(`{"module":"b","version":"1","seats":[{"name":"printer","scope":"mesh"}]}`), 0o600)
|
|
||||||
var out bytes.Buffer
|
|
||||||
if err := moduleCheck([]string{a, b}, &out); err == nil {
|
|
||||||
t.Fatalf("two declarations of one seat passed:\n%s", out.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(out.String(), "a seat name means one protocol") {
|
|
||||||
t.Fatalf("the cross-manifest rule was not the one named:\n%s", out.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The real catalogue passes the command, the way it passes the test that used to be the only check.
|
|
||||||
func TestModuleCheckPassesTheCatalogue(t *testing.T) {
|
|
||||||
root := filepath.Join("..", "..", "..", "mesh-catalog", "modules")
|
|
||||||
if _, err := os.Stat(root); err != nil {
|
|
||||||
t.Skipf("catalogue sibling not present: %v", err)
|
|
||||||
}
|
|
||||||
paths, err := manifestsUnder(root)
|
|
||||||
if err != nil || len(paths) == 0 {
|
|
||||||
t.Fatalf("no manifests under %s: %v", root, err)
|
|
||||||
}
|
|
||||||
var out bytes.Buffer
|
|
||||||
if err := moduleCheck(paths, &out); err != nil {
|
|
||||||
t.Fatalf("the catalogue does not pass its own check: %v\n%s", err, out.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistrationRefusesADefinitionNamingAnInstallation(t *testing.T) {
|
|
||||||
// novox/hq ADR 0155: the check moves to registration once the catalogue passes it. Both
|
|
||||||
// ways in — `module add` and a build's result — go through this, and a name declared on
|
|
||||||
// purpose passes with its reason.
|
|
||||||
named := catalogue.Manifest{Module: "idp", Resources: []map[string]any{
|
|
||||||
{"id": "server", "type": "container", "image": "x@sha256:aa",
|
|
||||||
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
|
|
||||||
}}
|
|
||||||
err := namesNoInstallation(named)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "login.mesh-one.be") ||
|
|
||||||
!strings.Contains(err.Error(), catalogue.NamesOnPurpose) {
|
|
||||||
t.Fatalf("a definition naming an installation is refused with the name and the way out; got %v", err)
|
|
||||||
}
|
|
||||||
meant := catalogue.Manifest{Module: "site", Resources: []map[string]any{
|
|
||||||
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
|
|
||||||
catalogue.NamesOnPurpose: map[string]any{
|
|
||||||
"registry.mesh-one.be": "built outside the mesh until its repository is a build source here"}},
|
|
||||||
}}
|
|
||||||
if err := namesNoInstallation(meant); err != nil {
|
|
||||||
t.Fatalf("a name declared on purpose passes; got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A merge that rebuilds a base rebuilds what stands on it, through every layer, and nothing else
|
|
||||||
// (novox/hq issue 186): the runtime image moving means every module built on it moves too, and a
|
|
||||||
// module built on one of those moves as well.
|
|
||||||
func TestAMergeOfABaseTakesWhatStandsOnItAlong(t *testing.T) {
|
|
||||||
entry := func(name string) inventory.Entry {
|
|
||||||
return inventory.Entry{Manifest: catalogue.Manifest{Module: name}}
|
|
||||||
}
|
|
||||||
entries := []inventory.Entry{entry("mesh-tools"), entry("shop"), entry("shop-plugin"), entry("postgres"), entry("unrelated")}
|
|
||||||
against := map[string][]string{
|
|
||||||
"shop": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"},
|
|
||||||
"shop-plugin": {catalogue.ArtifactStoreScheme + "shop/runtime@sha256:b"},
|
|
||||||
"postgres": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"},
|
|
||||||
"unrelated": {catalogue.ArtifactStoreScheme + "alpine/base@sha256:c"},
|
|
||||||
}
|
|
||||||
got := dependentsOf([]inventory.Entry{entry("mesh-tools")}, entries, against)
|
|
||||||
var names []string
|
|
||||||
for _, e := range got {
|
|
||||||
names = append(names, e.Manifest.Module)
|
|
||||||
}
|
|
||||||
want := map[string]bool{"shop": true, "shop-plugin": true, "postgres": true}
|
|
||||||
if len(names) != len(want) {
|
|
||||||
t.Fatalf("rebuilt %v; wanted exactly the three that stand on the runtime, directly or through shop", names)
|
|
||||||
}
|
|
||||||
for _, n := range names {
|
|
||||||
if !want[n] {
|
|
||||||
t.Fatalf("%s was rebuilt and stands on nothing that moved (%v)", n, names)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The dependents come in base order when the merge orders them: the runtime, then shop, then
|
|
||||||
// the plugin that stands on shop.
|
|
||||||
ordered := orderByBases(append([]inventory.Entry{entry("mesh-tools")}, got...), against)
|
|
||||||
pos := map[string]int{}
|
|
||||||
for i, e := range ordered {
|
|
||||||
pos[e.Manifest.Module] = i
|
|
||||||
}
|
|
||||||
if !(pos["mesh-tools"] < pos["shop"] && pos["shop"] < pos["shop-plugin"]) {
|
|
||||||
t.Fatalf("not in base order: %v", ordered)
|
|
||||||
}
|
|
||||||
// Nothing moved: nothing follows.
|
|
||||||
if more := dependentsOf(nil, entries, against); len(more) != 0 {
|
|
||||||
t.Fatalf("with nothing moved, %d module(s) were rebuilt", len(more))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// recordDerivedHolders writes down who holds each mesh-scoped seat that nobody was ever recorded
|
|
||||||
// as holding.
|
|
||||||
//
|
|
||||||
// **A seat held by derivation is a seat held by accident of being alone** (novox/hq
|
|
||||||
// 04-ISSUES/170). ADR 0131 lets a holder on record settle a seat, and lets any other assignment
|
|
||||||
// whose module could hold it stand beside the holder, eligible and silent. But a seat nobody
|
|
||||||
// ever handed over has no record, so its holder is whichever assignment happened to be the sole
|
|
||||||
// claimant — and the day a second one is assigned, both claim, both are refused, and the first
|
|
||||||
// one's whole machine stops resolving. That is what assigning a second postgres did to the
|
|
||||||
// control plane's own store.
|
|
||||||
//
|
|
||||||
// So the mesh writes the derived answer down before it acts on an assignment: for every
|
|
||||||
// mesh-scoped seat with exactly one resolved holder and nothing on record, that holder is
|
|
||||||
// recorded as the standing one — the same record `seat <name> --to <node>/<module>` makes by
|
|
||||||
// hand, made from what the mesh already resolved. A seat with two derived claimants is left
|
|
||||||
// alone: that is the ambiguity a person settles, and recording either would be guessing.
|
|
||||||
//
|
|
||||||
// Node-scoped seats are untouched: a record is one holder per seat, and a node-scoped seat has
|
|
||||||
// one holder per machine (ADR 0121), so there is nothing for a record to settle there.
|
|
||||||
func recordDerivedHolders(ctx context.Context, open *stores) ([]string, error) {
|
|
||||||
inv := open.inventory
|
|
||||||
shelf, err := inv.Catalogue(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// exclude nobody: every node's claims, resolved with the holdings on record.
|
|
||||||
world, err := theRestOfTheMesh(ctx, inv, shelf, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
recorded, err := inv.Holdings(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// A record is a row against a seat the store knows. A seat it does not — a mesh whose seats
|
|
||||||
// were never seeded, a seat a module declares for itself — stays held by derivation, as it
|
|
||||||
// always was; a missing row is not a reason an assignment fails.
|
|
||||||
known, err := inv.Seats(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
recordable := map[string]bool{}
|
|
||||||
for _, s := range known {
|
|
||||||
recordable[s.Name] = true
|
|
||||||
}
|
|
||||||
onRecord := map[string]bool{}
|
|
||||||
for _, h := range recorded {
|
|
||||||
if s, ok := catalogue.SeatNamed(h.Claim); ok {
|
|
||||||
onRecord[s.Name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
holders := map[string][]catalogue.Held{}
|
|
||||||
for _, h := range world.Held {
|
|
||||||
if h.Scope != catalogue.ScopeMesh {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s, ok := catalogue.SeatNamed(h.Claim)
|
|
||||||
if !ok || onRecord[s.Name] || !recordable[s.Name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
holders[s.Name] = append(holders[s.Name], h)
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(holders))
|
|
||||||
for name := range holders {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
var said []string
|
|
||||||
for _, name := range names {
|
|
||||||
if len(holders[name]) != 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
h := holders[name][0]
|
|
||||||
if err := inv.HoldSeat(ctx, name, catalogue.ScopeMesh, h.Node, h.Module); err != nil {
|
|
||||||
return said, err
|
|
||||||
}
|
|
||||||
said = append(said, fmt.Sprintf(
|
|
||||||
"recorded %s on %s as the standing holder of %s, which it held only by being alone",
|
|
||||||
h.Module, h.Node, name))
|
|
||||||
}
|
|
||||||
return said, nil
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A seat nobody ever handed over is held by whichever assignment happened to be alone — and the
|
|
||||||
// day a second module able to hold it is assigned, both claimed, both were refused, and the first
|
|
||||||
// one's machine stopped resolving (novox/hq 04-ISSUES/170). The mesh now writes the derived holder
|
|
||||||
// down before it acts, so the second assignment stands beside the holder on record.
|
|
||||||
|
|
||||||
func aSeatedStore() catalogue.Manifest {
|
|
||||||
return catalogue.Manifest{Module: "store", Version: "1",
|
|
||||||
Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}},
|
|
||||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
|
|
||||||
Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestASecondEligibleHolderStandsBesideTheOneHeldByBeingAlone(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
// Every deploy seeds the mesh's own seats; a record is a row against one of them.
|
|
||||||
if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
register(t, open, aSeatedStore())
|
|
||||||
|
|
||||||
if _, err := assign(ctx, open, "anchor", "store"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
said, err := assign(ctx, open, "laptop", "store")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("a second store, eligible for the seat, was refused:\n%s\n%v", said, err)
|
|
||||||
}
|
|
||||||
if strings.Contains(said, "cannot be worked out") {
|
|
||||||
t.Fatalf("assigning a second store unsettled the first one's machine:\n%s", said)
|
|
||||||
}
|
|
||||||
if !strings.Contains(said, "recorded store on anchor as the standing holder of mesh-store") {
|
|
||||||
t.Fatalf("the holder by derivation was not written down:\n%s", said)
|
|
||||||
}
|
|
||||||
|
|
||||||
holdings, err := open.inventory.Holdings(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var found bool
|
|
||||||
for _, h := range holdings {
|
|
||||||
if h.Claim == "mesh-store" {
|
|
||||||
found = true
|
|
||||||
if h.Node != "anchor" || h.Module != "store" {
|
|
||||||
t.Fatalf("mesh-store is recorded on %s/%s, not on the one that held it", h.Node, h.Module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatalf("mesh-store has no holder on record after assigning: %v", holdings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the record decides from here: the anchor's plan holds the seat, the laptop's does not.
|
|
||||||
for node, holds := range map[string]bool{"anchor": true, "laptop": false} {
|
|
||||||
plan, _, err := planFor(ctx, open, node)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s no longer resolves: %v", node, err)
|
|
||||||
}
|
|
||||||
var claimed bool
|
|
||||||
for _, c := range plan.Claims {
|
|
||||||
if c.Claim == "mesh-store" {
|
|
||||||
claimed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if claimed != holds {
|
|
||||||
t.Fatalf("%s holds mesh-store: %v, want %v", node, claimed, holds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAHolderOnRecordIsNotRewrittenByDerivation(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
register(t, open, aSeatedStore())
|
|
||||||
for _, node := range []string{"anchor", "laptop"} {
|
|
||||||
if _, err := assign(ctx, open, node, "store"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A person hands the seat to the laptop. From here the record decides, and what the mesh
|
|
||||||
// derives must never write over it.
|
|
||||||
if err := open.inventory.HoldSeat(ctx, "mesh-store", catalogue.ScopeMesh, "laptop", "store"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
said, err := recordDerivedHolders(ctx, open)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(said) != 0 {
|
|
||||||
t.Fatalf("a seat on record was written again from derivation: %v", said)
|
|
||||||
}
|
|
||||||
holdings, _ := open.inventory.Holdings(ctx)
|
|
||||||
for _, h := range holdings {
|
|
||||||
if h.Claim == "mesh-store" && h.Node != "laptop" {
|
|
||||||
t.Fatalf("the record moved to %s", h.Node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -72,8 +72,6 @@ func run() error {
|
|||||||
return askCommand(ctx, args[1:])
|
return askCommand(ctx, args[1:])
|
||||||
case "builds":
|
case "builds":
|
||||||
return buildsCommand(ctx, args[1:])
|
return buildsCommand(ctx, args[1:])
|
||||||
case "plans":
|
|
||||||
return plansCommand(ctx, args[1:])
|
|
||||||
case "pin":
|
case "pin":
|
||||||
return pinCommand(ctx, args[1:], true)
|
return pinCommand(ctx, args[1:], true)
|
||||||
case "unpin":
|
case "unpin":
|
||||||
@@ -163,7 +161,6 @@ func usage() {
|
|||||||
overlay place <node> [flags] say where a node is and how it is reached
|
overlay place <node> [flags] say where a node is and how it is reached
|
||||||
overlay show the private network, as the mesh computes it
|
overlay show the private network, as the mesh computes it
|
||||||
module add <file> register a module from its manifest
|
module add <file> register a module from its manifest
|
||||||
module check <file|dir>... judge manifests where they are written, with no mesh (exit 1 on any problem)
|
|
||||||
module list what modules this mesh knows about
|
module list what modules this mesh knows about
|
||||||
module moved <name> <commit> the source has a newer commit than the mesh built
|
module moved <name> <commit> the source has a newer commit than the mesh built
|
||||||
module forget <name> remove one, unless a node runs it or the mesh holds things for it
|
module forget <name> remove one, unless a node runs it or the mesh holds things for it
|
||||||
@@ -174,8 +171,6 @@ func usage() {
|
|||||||
upgrade <name> record ...record that they are behind, and send nothing
|
upgrade <name> record ...record that they are behind, and send nothing
|
||||||
status [--json] what is wrong, what is quiet, and what is out of date
|
status [--json] what is wrong, what is quiet, and what is out of date
|
||||||
seats [--json] every seat this mesh defines, what it delivers, and who holds it
|
seats [--json] every seat this mesh defines, what it delivers, and who holds it
|
||||||
seat rename <from> <to> rename a seat; its former name still resolves (ADR 0122)
|
|
||||||
seat <name> --to <node>/<module> hand a seat to that assignment as one act; never empty in between (ADR 0131)
|
|
||||||
board [--listen ADDR] the same three questions, as a page that holds nothing
|
board [--listen ADDR] the same three questions, as a page that holds nothing
|
||||||
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
||||||
assign <node> <module> put a module on a node
|
assign <node> <module> put a module on a node
|
||||||
@@ -205,8 +200,7 @@ func usage() {
|
|||||||
licence refresh <name> mint a new access token and seal it to every holder
|
licence refresh <name> mint a new access token and seal it to every holder
|
||||||
rotate <provision> [--consumer <n>] a new credential for every holder, both ends at once
|
rotate <provision> [--consumer <n>] a new credential for every holder, both ends at once
|
||||||
ask <module> <tool> [json] call one of a module's tools over the broker, and print its answer
|
ask <module> <tool> [json] call one of a module's tools over the broker, and print its answer
|
||||||
pin <node> <provision> <from-node> <module>
|
pin <node> <provision> <from> which node this one gets a provision from
|
||||||
which provider this one gets a provision from: the module, and its node
|
|
||||||
unpin <node> <provision> put that question back
|
unpin <node> <provision> put that question back
|
||||||
plan <node> [--files|--json] what that node would run, and why
|
plan <node> [--files|--json] what that node would run, and why
|
||||||
push [<node>] [--behind] send a node everything it should be, or only those that need it
|
push [<node>] [--behind] send a node everything it should be, or only those that need it
|
||||||
@@ -246,31 +240,6 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Built is the daemon hearing a build's outcome on the bus — its own asking, an announcement's, or
|
|
||||||
// a tool's that did not wait (novox/hq issue 176) — and taking it in: recorded, and the module
|
|
||||||
// registered, the same as the waiting command does. Said either way, so the daemon's log tells what
|
|
||||||
// became of a build nobody was watching.
|
|
||||||
func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
||||||
manifest, _, err := takeIn(ctx, b.inv, result)
|
return b.inv.RecordBuild(ctx, buildFrom(result))
|
||||||
switch {
|
|
||||||
case err != nil && result.Failed != "":
|
|
||||||
fmt.Printf("%s: %v\n", result.ID, err)
|
|
||||||
if result.Module != "" {
|
|
||||||
planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed)
|
|
||||||
} else {
|
|
||||||
planFailedBuild(ctx, b.open, result)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case err != nil:
|
|
||||||
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
|
|
||||||
if manifest.Module != "" {
|
|
||||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, err.Error())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
|
||||||
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
|
||||||
saysWhenThePolicyActs(ctx, b.inv, manifest.Module)
|
|
||||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,24 +54,7 @@ var provided = providedModules()
|
|||||||
|
|
||||||
func moduleCommand(ctx context.Context, args []string) error {
|
func moduleCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("module add <file>, module check <file>..., module list, or module forget <name>")
|
return errors.New("module add <file>, module list, or module forget <name>")
|
||||||
}
|
|
||||||
// `check` needs no mesh, and must not: it is what somebody runs in their own repository before
|
|
||||||
// there is a mesh in reach (novox/hq issue 148). A directory expands to every manifest under it.
|
|
||||||
if args[0] == "check" {
|
|
||||||
var paths []string
|
|
||||||
for _, a := range args[1:] {
|
|
||||||
if info, err := os.Stat(a); err == nil && info.IsDir() {
|
|
||||||
under, err := manifestsUnder(a)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
paths = append(paths, under...)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
paths = append(paths, a)
|
|
||||||
}
|
|
||||||
return moduleCheck(paths, os.Stdout)
|
|
||||||
}
|
}
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -113,9 +96,6 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := namesNoInstallation(m); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := inv.RegisterModule(ctx, m, from); err != nil {
|
if err := inv.RegisterModule(ctx, m, from); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -295,7 +275,7 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
return issueOnTheNewBus(ctx, inv, m, *forNode, busAddress)
|
return issueOnTheNewBus(ctx, inv, m, *forNode, busAddress)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("module has no %q; it has add, check, list, moved, forget and issue", args[0])
|
return fmt.Errorf("module has no %q; it has add, list, moved, forget and issue", args[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,8 +391,8 @@ func describeOffers(offers []catalogue.Offer) string {
|
|||||||
// database should not change where an existing machine gets its data the day a second one
|
// database should not change where an existing machine gets its data the day a second one
|
||||||
// arrives.
|
// arrives.
|
||||||
func pinCommand(ctx context.Context, args []string, setting bool) error {
|
func pinCommand(ctx context.Context, args []string, setting bool) error {
|
||||||
if setting && len(args) != 4 {
|
if setting && len(args) != 3 {
|
||||||
return errors.New("pin <node> <provision> <from-node> <module>")
|
return errors.New("pin <node> <provision> <from-node>")
|
||||||
}
|
}
|
||||||
if !setting && len(args) != 2 {
|
if !setting && len(args) != 2 {
|
||||||
return errors.New("unpin <node> <provision>")
|
return errors.New("unpin <node> <provision>")
|
||||||
@@ -431,12 +411,17 @@ func pinCommand(ctx context.Context, args []string, setting bool) error {
|
|||||||
fmt.Printf("%s is no longer told where to get %s from\n", args[0], args[1])
|
fmt.Printf("%s is no longer told where to get %s from\n", args[0], args[1])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// The provider's node may be this same machine: two modules beside the consumer can both
|
if args[0] == args[2] {
|
||||||
// answer a provision, and then the module is the whole question (novox/hq #258).
|
// Allowed by nothing here, and worth saying rather than resolving into a confusing
|
||||||
if err := inv.PinProvision(ctx, args[0], args[1], args[2], args[3]); err != nil {
|
// refusal later: a node providing something to itself is a node-scoped provision, and
|
||||||
|
// this field is for the other kind.
|
||||||
|
return fmt.Errorf("%s cannot get %s from itself; that would be a provision this machine "+
|
||||||
|
"provides, which does not need saying", args[0], args[1])
|
||||||
|
}
|
||||||
|
if err := inv.PinProvision(ctx, args[0], args[1], args[2]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s gets %s from %s/%s\n", args[0], args[1], args[2], args[3])
|
fmt.Printf("%s gets %s from %s\n", args[0], args[1], args[2])
|
||||||
fmt.Printf(" run `push %s` to send it\n", args[0])
|
fmt.Printf(" run `push %s` to send it\n", args[0])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -579,25 +564,16 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
|
|||||||
// (`rollout mint`, design 28 task 5.2) rather than the one in this process's environment.
|
// (`rollout mint`, design 28 task 5.2) rather than the one in this process's environment.
|
||||||
func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest,
|
func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest,
|
||||||
node, busAddress string, known broker.Broker, reachable, user, password string) error {
|
node, busAddress string, known broker.Broker, reachable, user, password string) error {
|
||||||
// The seats this module claims, with the verbs each promises (novox/hq ADR 0159): the runtime
|
|
||||||
// serves a claimed seat's verbs with its tools of the same name, and the bus admits only the
|
|
||||||
// holder's subscription — so the runtime tries each claim and the grant decides. Written here
|
|
||||||
// because this file is the one thing the mesh writes that the runtime reads before it speaks.
|
|
||||||
claims, err := claimsFor(ctx, inv, m)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
held, err := json.Marshal(struct {
|
held, err := json.Marshal(struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Fingerprint string `json:"fingerprint,omitempty"`
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Claims []seatClaimed `json:"claims,omitempty"`
|
|
||||||
}{
|
}{
|
||||||
URL: "nats://" + reachable, Fingerprint: known.Fingerprint,
|
URL: "nats://" + reachable, Fingerprint: known.Fingerprint,
|
||||||
Node: node, Module: m.Module, User: user, Password: password, Claims: claims,
|
Node: node, Module: m.Module, User: user, Password: password,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -669,53 +645,3 @@ func whereItComesFrom(repository, ref, commit, path string, self bool) (inventor
|
|||||||
}
|
}
|
||||||
return from, nil
|
return from, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// namesNoInstallation is the mesh refusing a definition that names an installation, at the moment
|
|
||||||
// it would enter the catalogue (novox/hq ADR 0112, ADR 0155). `module check` says the same thing
|
|
||||||
// earlier, where the author is; this is the last moment the mesh can still say no, and a
|
|
||||||
// definition that got past the check — written elsewhere, or checked by nobody — is refused here
|
|
||||||
// in the same words. A name meant on purpose is declared with its reason and passes.
|
|
||||||
func namesNoInstallation(m catalogue.Manifest) error {
|
|
||||||
named := catalogue.InstallationProblems(m)
|
|
||||||
if len(named) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%s names an installation, and a definition names none — declare a name meant "+
|
|
||||||
"on purpose under %s with its reason, or take it out:\n - %s",
|
|
||||||
m.Module, catalogue.NamesOnPurpose, strings.Join(named, "\n - "))
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatClaimed is one seat a module claims, as its runtime needs it: the name, the scope (a
|
|
||||||
// node-scoped seat's verb carries the machine, design 33 §4) and the verbs the seat promises.
|
|
||||||
type seatClaimed struct {
|
|
||||||
Seat string `json:"seat"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
Serves []string `json:"serves,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimsFor joins a module's claims with the seats' protocols from the mesh's records.
|
|
||||||
func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest) ([]seatClaimed, error) {
|
|
||||||
if len(m.Claims) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
seats, err := inv.Seats(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
byName := map[string]catalogue.Seat{}
|
|
||||||
for _, s := range seats {
|
|
||||||
byName[s.Name] = s
|
|
||||||
}
|
|
||||||
var out []seatClaimed
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
claimed := seatClaimed{Seat: c.Name, Scope: c.At()}
|
|
||||||
if s, known := byName[c.Name]; known {
|
|
||||||
claimed.Scope = s.Scope
|
|
||||||
// The verbs the runtime serves for the seat: the claim's own when it names them
|
|
||||||
// (ADR 0160), else every verb the seat promises, which its tools then answer.
|
|
||||||
claimed.Serves = c.ServesFor(catalogue.Manifest{Tools: catalogue.VerbNames(s.Serves)})
|
|
||||||
}
|
|
||||||
out = append(out, claimed)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// A module that declares none is refused before the account exists, so the bus never carries an
|
// A module that declares none is refused before the account exists, so the bus never carries an
|
||||||
// account nothing reads (novox/hq 04-ISSUES/078).
|
// account nothing reads (novox/hq 04-ISSUES/078).
|
||||||
func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) {
|
func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) {
|
||||||
err := mayIssue(catalogue.Manifest{Module: "step-ca", OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}})
|
err := mayIssue(catalogue.Manifest{Module: "step-ca", OwnSecrets: map[string]string{"password": "/run/password"}})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a module with no broker own secret was issued an account")
|
t.Fatal("a module with no broker own secret was issued an account")
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ func TestAModuleWithNoBrokerSecretCannotBeIssued(t *testing.T) {
|
|||||||
t.Errorf("the refusal does not say %q: %v", want, err)
|
t.Errorf("the refusal does not say %q: %v", want, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := mayIssue(catalogue.Manifest{Module: "redis", OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}); err != nil {
|
if err := mayIssue(catalogue.Manifest{Module: "redis", OwnSecrets: map[string]string{"broker": "/run/broker"}}); err != nil {
|
||||||
t.Errorf("a module declaring its broker secret was refused: %v", err)
|
t.Errorf("a module declaring its broker secret was refused: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -567,9 +567,6 @@ func onTheNetwork(ctx context.Context, inv *inventory.Inventory,
|
|||||||
catalogue.Node{Name: p.Name, Site: p.Site, Capabilities: caps},
|
catalogue.Node{Name: p.Name, Site: p.Site, Capabilities: caps},
|
||||||
catalogue.World{Unchecked: true, Holdings: holdings})
|
catalogue.World{Unchecked: true, Holdings: holdings})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Said, not skipped in silence: a machine dropped here loses its address, and every
|
|
||||||
// plan that names it fails in another module's words (novox/hq issue 188).
|
|
||||||
fmt.Fprintf(os.Stderr, "%s is not counted as on the network: it does not resolve: %v\n", p.Name, err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, m := range got.Modules {
|
for _, m := range got.Modules {
|
||||||
|
|||||||
+39
-65
@@ -7,7 +7,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -164,14 +163,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var secret inventory.Secret
|
secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
|
||||||
var err error
|
|
||||||
if n.SharedOwn != "" {
|
|
||||||
// The provider's one credential, sealed to this consumer too (novox/hq ADR 0158).
|
|
||||||
secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn)
|
|
||||||
} else {
|
|
||||||
secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Said rather than skipped. A machine that resolves cleanly and receives no
|
// Said rather than skipped. A machine that resolves cleanly and receives no
|
||||||
// credential is one that will fail to authenticate at some later, less obvious
|
// credential is one that will fail to authenticate at some later, less obvious
|
||||||
@@ -282,9 +274,8 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
for _, o := range others {
|
for _, o := range others {
|
||||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings})
|
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Said, not skipped: a machine dropped here offers nothing and holds nothing as far
|
// Their set does not resolve for some other reason. Not this node's problem to
|
||||||
// as every other machine's plan can tell (novox/hq issue 188).
|
// report, and nothing of theirs is running, so it offers nothing.
|
||||||
fmt.Fprintf(os.Stderr, "%s is left out of the rest of the mesh: it does not resolve: %v\n", o.node.Name, err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
firstHeld = append(firstHeld, got.Claims...)
|
firstHeld = append(firstHeld, got.Claims...)
|
||||||
@@ -315,22 +306,8 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings}
|
world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings}
|
||||||
var held []catalogue.Held
|
var held []catalogue.Held
|
||||||
for _, o := range others {
|
for _, o := range others {
|
||||||
// Each machine is resolved with its own pins, as its plan is: a machine that needs one to
|
got, err := catalogue.Resolve(shelf, o.assigned, o.node, world)
|
||||||
// settle two providers would otherwise be refused here and vanish from the mesh — every
|
|
||||||
// seat it holds unheld, every build that needs one refused (2026-10-01, the control node;
|
|
||||||
// novox/hq issue 188).
|
|
||||||
theirs := world
|
|
||||||
if pins, err := inv.PinsFor(ctx, o.node.Name); err == nil {
|
|
||||||
theirs.Pinned = pins
|
|
||||||
}
|
|
||||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, theirs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Said only for the whole-mesh view. With one machine excluded, the others are
|
|
||||||
// resolved without its offers, and one that consumes them cannot resolve here by
|
|
||||||
// design — that is not the machine being dropped, it is the view being partial.
|
|
||||||
if exclude == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s is left out of the rest of the mesh: it does not resolve: %v\n", o.node.Name, err)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
held = append(held, got.Claims...)
|
held = append(held, got.Claims...)
|
||||||
@@ -404,8 +381,7 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
}
|
}
|
||||||
return sendable{Resources: composed.Resources, Adoption: adoption,
|
return sendable{Resources: composed.Resources, Adoption: adoption}, nil
|
||||||
Received: composed.Received, Mesh: with.Mesh}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
||||||
@@ -755,13 +731,9 @@ func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string,
|
|||||||
return nil, fmt.Errorf("which machines the mesh has cannot be read: %w", err)
|
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
|
out := map[string]string{}
|
||||||
// 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 {
|
for _, n := range nodes {
|
||||||
plan, layers, err := planFor(ctx, open, n.Name)
|
plan, settings, err := planFor(ctx, open, n.Name)
|
||||||
switch {
|
switch {
|
||||||
case unresolvable(err):
|
case unresolvable(err):
|
||||||
// Their set does not compose, so they serve no names. Passed over, so one machine's
|
// Their set does not compose, so they serve no names. Passed over, so one machine's
|
||||||
@@ -773,16 +745,38 @@ func routeNamesInTheMesh(ctx context.Context, open *stores) (map[string]string,
|
|||||||
// operator having withdrawn them (novox/hq 04-ISSUES/152).
|
// operator having withdrawn them (novox/hq 04-ISSUES/152).
|
||||||
return nil, fmt.Errorf("the names %s serves cannot be read: %w", n.Name, err)
|
return nil, fmt.Errorf("the names %s serves cannot be read: %w", n.Name, err)
|
||||||
}
|
}
|
||||||
plans[n.Name], settings[n.Name] = plan, layers
|
for _, m := range plan.Modules {
|
||||||
}
|
for to := range m.Contributes {
|
||||||
served, err := catalogue.NamesServed(plans, settings)
|
values, asks, err := plan.ContributionsFrom(to, m.Module, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := map[string]string{}
|
if !asks {
|
||||||
for name, node := range served {
|
continue
|
||||||
if at := address[node]; at != "" {
|
}
|
||||||
out[name] = at
|
// 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 := values["label"]; !labelled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _ := values["name"].(string)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The node that serves it: whoever answers this consumer's route requirement, or
|
||||||
|
// this same node when the proxy is beside the consumer.
|
||||||
|
serving := n.Name
|
||||||
|
for _, need := range plan.Needs {
|
||||||
|
if need.Name == to && need.For == m.Module {
|
||||||
|
serving = need.From
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at := address[serving]; at != "" {
|
||||||
|
out[strings.ToLower(name)] = at
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -1362,23 +1356,3 @@ func foundationPortsFor(brokerPort int, modules []catalogue.Manifest) []int {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// providerModuleOf is which module answers a need on the providing node: the one in this node's
|
|
||||||
// own set when the provider is here, else the one the catalogue says offers it.
|
|
||||||
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
|
|
||||||
for _, m := range resolved.Modules {
|
|
||||||
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
|
||||||
return m.Module
|
|
||||||
}
|
|
||||||
}
|
|
||||||
shelf, err := open.inventory.Catalogue(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for name, m := range shelf {
|
|
||||||
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-121
@@ -4,11 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -113,10 +111,7 @@ func serve(ctx context.Context) error {
|
|||||||
// while everything else about it looks correct.
|
// while everything else about it looks correct.
|
||||||
// And build results nobody was waiting for. A build triggered any other way than `build`
|
// And build results nobody was waiting for. A build triggered any other way than `build`
|
||||||
// would otherwise be reported into the void, which is the same as not reporting it.
|
// would otherwise be reported into the void, which is the same as not reporting it.
|
||||||
server.Records(builds{inv, open})
|
server.Records(builds{inv})
|
||||||
// Open plans move on a timer as well as on outcomes (novox/hq ADR 0162): a tier waiting for
|
|
||||||
// machines to report moves when they have, and a plan left by a replaced controller resumes.
|
|
||||||
go planTicker(ctx, open)
|
|
||||||
// And what the catalogue decided a build meant. The builder's own result is already handled
|
// And what the catalogue decided a build meant. The builder's own result is already handled
|
||||||
// above; this is the other half — the control plane is the only one of the three that knows
|
// above; this is the other half — the control plane is the only one of the three that knows
|
||||||
// which machines run the thing, so it is the one that acts (novox/hq ADR 0072).
|
// which machines run the thing, so it is the one that acts (novox/hq ADR 0072).
|
||||||
@@ -129,22 +124,6 @@ func serve(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served
|
|
||||||
// from the store's row, so what the seat declares is what is answered.
|
|
||||||
handlers, err := seatToolHandlers()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
bus, isNATS := server.Bus().(link.OverNATS)
|
|
||||||
if !isNATS {
|
|
||||||
return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it")
|
|
||||||
}
|
|
||||||
stopServing, err := bus.ServeSeatTools(catalogue.ControllerSeatName, handlers, log.New(os.Stdout, "", log.LstdFlags))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer stopServing()
|
|
||||||
|
|
||||||
return server.Serve(ctx)
|
return server.Serve(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,12 +338,6 @@ 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
|
|
||||||
// inside the signed bytes, so a replayed older declaration cannot borrow a newer one's
|
|
||||||
// (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
|
||||||
@@ -387,11 +360,6 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
release()
|
release()
|
||||||
fmt.Printf("\n%d node(s) told\n", len(sending))
|
fmt.Printf("\n%d node(s) told\n", len(sending))
|
||||||
// And each machine's memberships, as every other send does (ADR 0160): a push is the one most
|
|
||||||
// operators run, and on 2026-10-01 it was the one path that issued none.
|
|
||||||
if err := issueMemberships(ctx, open, server, sending); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A named push leaves the mesh consistent, not just the machine it named** (novox/hq
|
// **A named push leaves the mesh consistent, not just the machine it named** (novox/hq
|
||||||
// issue 057, ADR 0083). Assigning a cross-node consumer mints a provision, and the PROVIDER's
|
// issue 057, ADR 0083). Assigning a cross-node consumer mints a provision, and the PROVIDER's
|
||||||
@@ -596,9 +564,6 @@ func sendRound(ctx context.Context, open *stores, names []string,
|
|||||||
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
|
||||||
@@ -680,9 +645,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
|
||||||
@@ -699,68 +661,6 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
}
|
}
|
||||||
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))
|
||||||
}
|
}
|
||||||
// And every assignment on those machines its membership (novox/hq ADR 0160): composed from the
|
|
||||||
// same records the bus's accounts are, so what a runtime serves and what its account may are one
|
|
||||||
// composition. Issued after the declaration, because the runtime it is for arrives with it.
|
|
||||||
return issueMemberships(ctx, open, server, sending)
|
|
||||||
}
|
|
||||||
|
|
||||||
// issueMemberships publishes the membership of every module on the machines just sent.
|
|
||||||
//
|
|
||||||
// Each carries what its module receives and the private network's addresses, from the same
|
|
||||||
// composition as the declaration it was sent (novox/hq ADR 0167): a provider reads what it is
|
|
||||||
// given on the bus, and the file written beside it says the same thing.
|
|
||||||
func issueMemberships(ctx context.Context, open *stores, server *link.Server, sent []readyNode) error {
|
|
||||||
records, err := open.inventory.BusRecords(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
|
||||||
bus, ok := server.Bus().(link.OverNATS)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// The declarations are sent and recorded by now; a membership that cannot be issued is said
|
|
||||||
// and does not unsay them. Every runtime without one serves the shape it derives (ADR 0160), so
|
|
||||||
// the push stands, the first failure is named once, and the next push tries again.
|
|
||||||
issued, failed := 0, 0
|
|
||||||
var first error
|
|
||||||
for _, s := range sent {
|
|
||||||
node := s.node
|
|
||||||
for _, d := range records.Assigned[node] {
|
|
||||||
membership := broker.MembershipFor(node, d, where)
|
|
||||||
membership.Mesh = s.declared.Mesh
|
|
||||||
for requirement, given := range s.declared.Received[d.Module] {
|
|
||||||
raw, err := json.Marshal(given)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if membership.Receives == nil {
|
|
||||||
membership.Receives = map[string]json.RawMessage{}
|
|
||||||
}
|
|
||||||
membership.Receives[requirement] = raw
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(membership)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := bus.PublishMembership(ctx, node, d.Module, body); err != nil {
|
|
||||||
if first == nil {
|
|
||||||
first = err
|
|
||||||
}
|
|
||||||
failed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
issued++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if issued > 0 {
|
|
||||||
fmt.Printf(" issued %d membership(s)\n", issued)
|
|
||||||
}
|
|
||||||
if failed > 0 {
|
|
||||||
fmt.Printf(" %d membership(s) could not be issued; the first: %v — the machines keep what "+
|
|
||||||
"they derive until the next push\n", failed, first)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,12 +694,6 @@ func wouldSend(ctx context.Context, open *stores,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Composed with the number the machine was LAST sent, so this is byte for byte what it was
|
|
||||||
// sent when nothing else changed. A fresh number here would make every machine read as
|
|
||||||
// behind for ever (novox/hq 04-ISSUES/107).
|
|
||||||
if declared.Sequence, err = open.inventory.Sequence(ctx, n.ID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
body, err := declared.Body()
|
body, err := declared.Body()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -933,17 +827,3 @@ func seatHolders(ctx context.Context, inv *inventory.Inventory) (map[string]brok
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
|
||||||
record, err := inv.NodeByName(ctx, s.node)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
seq, err := inv.NextSequence(ctx, record.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.declared.Sequence = seq
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,9 +39,6 @@ type meshStatus struct {
|
|||||||
// whose is older is still working — and Waiting cannot tell those apart, because the sent
|
// whose is older is still working — and Waiting cannot tell those apart, because the sent
|
||||||
// digest is recorded at send, not at apply.
|
// digest is recorded at send, not at apply.
|
||||||
Reported []machineReported `json:"reported"`
|
Reported []machineReported `json:"reported"`
|
||||||
// Plans is what the last merges produced and where each stands (novox/hq ADR 0162): the
|
|
||||||
// open ones first, each saying its tier, what it waits for, and whether it has waited too long.
|
|
||||||
Plans []planStatus `json:"plans"`
|
|
||||||
// Unresolved is every machine that cannot be worked out at all, with what the mesh said when
|
// Unresolved is every machine that cannot be worked out at all, with what the mesh said when
|
||||||
// it tried. **A machine here is in none of the lists above**: nothing was computed for it, so
|
// it tried. **A machine here is in none of the lists above**: nothing was computed for it, so
|
||||||
// there is nothing to compare it against and nothing it can be behind — which is why a
|
// there is nothing to compare it against and nothing it can be behind — which is why a
|
||||||
@@ -155,7 +152,7 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
|||||||
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
|
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
|
||||||
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
||||||
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
||||||
Network: asked.network, Adopted: adoptedNodes(nodes), Plans: planStatuses(asked.plans, time.Now())}
|
Network: asked.network, Adopted: adoptedNodes(nodes)}
|
||||||
// In a stated order, so two readings of an unchanged mesh are the same document.
|
// In a stated order, so two readings of an unchanged mesh are the same document.
|
||||||
untakenNodes := make([]string, 0, len(asked.untaken))
|
untakenNodes := make([]string, 0, len(asked.untaken))
|
||||||
for name := range asked.untaken {
|
for name := range asked.untaken {
|
||||||
|
|||||||
@@ -1,742 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A merge produces a tiered plan the mesh keeps (novox/hq ADR 0162).
|
|
||||||
//
|
|
||||||
// The handler that hears the merge computes the plan from the catalogue's one dependency relation,
|
|
||||||
// writes it to the store, asks the first tier and returns — the receive loop is never held by a
|
|
||||||
// build. Every outcome taken in advances the plan it belongs to; a ticker advances what outcomes
|
|
||||||
// alone cannot (a tier waiting for machines to report); a controller replaced mid-plan finds the
|
|
||||||
// plan where it left it.
|
|
||||||
|
|
||||||
// planWaitBound is how long a plan may wait on one thing before `status` names it red.
|
|
||||||
const planWaitBound = 30 * time.Minute
|
|
||||||
|
|
||||||
// tiersOf sorts a set of modules into tiers along the ordering edges among them: tier 0 depends
|
|
||||||
// on nothing else in the set, tier 1 only on tier 0, and so on. An edge to a module outside the set says
|
|
||||||
// nothing about the order inside it. A cycle — which the catalogue should never produce — puts
|
|
||||||
// what remains in one last tier rather than losing it, and is said by the caller.
|
|
||||||
func tiersOf(set []string, edges []inventory.Edge) [][]string {
|
|
||||||
in := map[string]bool{}
|
|
||||||
for _, m := range set {
|
|
||||||
in[m] = true
|
|
||||||
}
|
|
||||||
deps := map[string]map[string]bool{}
|
|
||||||
for _, m := range set {
|
|
||||||
deps[m] = map[string]bool{}
|
|
||||||
}
|
|
||||||
for _, e := range edges {
|
|
||||||
// A code dependency — B packages A's source — rebuilds B with A, in the same tier: B's
|
|
||||||
// build needs nothing of A's first. The other kinds order: stands-on and declared after
|
|
||||||
// the base is built, built-by after the build machine is built and running — except for
|
|
||||||
// what the build machine itself stands on. The runtime image is built by the builder and
|
|
||||||
// the builder is built on the runtime image; the image comes first, built by the builder
|
|
||||||
// that is running, which is the only one there could be.
|
|
||||||
if !in[e.From] || !in[e.To] || e.From == e.To || e.Kind == inventory.EdgePackages {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if e.Kind == inventory.EdgeBuiltBy && isBaseOf(e.From, e.To, edges, in) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
deps[e.From][e.To] = true
|
|
||||||
}
|
|
||||||
placed := map[string]bool{}
|
|
||||||
var tiers [][]string
|
|
||||||
for len(placed) < len(set) {
|
|
||||||
var tier []string
|
|
||||||
for _, m := range set {
|
|
||||||
if placed[m] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
free := true
|
|
||||||
for d := range deps[m] {
|
|
||||||
if !placed[d] {
|
|
||||||
free = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if free {
|
|
||||||
tier = append(tier, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(tier) == 0 {
|
|
||||||
// A cycle: everything left, together, and the caller says so.
|
|
||||||
for _, m := range set {
|
|
||||||
if !placed[m] {
|
|
||||||
tier = append(tier, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(tier)
|
|
||||||
for _, m := range tier {
|
|
||||||
placed[m] = true
|
|
||||||
}
|
|
||||||
tiers = append(tiers, tier)
|
|
||||||
}
|
|
||||||
return tiers
|
|
||||||
}
|
|
||||||
|
|
||||||
// isBaseOf says whether `to` stands on `base`, directly or through other bases in the set, along
|
|
||||||
// the build edges alone.
|
|
||||||
func isBaseOf(base, to string, edges []inventory.Edge, in map[string]bool) bool {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var walk func(string) bool
|
|
||||||
walk = func(m string) bool {
|
|
||||||
if m == base {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if seen[m] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
seen[m] = true
|
|
||||||
for _, e := range edges {
|
|
||||||
if e.From == m && in[e.To] && (e.Kind == inventory.EdgeStandsOn || e.Kind == inventory.EdgeDeclared) && walk(e.To) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return walk(to)
|
|
||||||
}
|
|
||||||
|
|
||||||
// reachableFrom is the moved modules plus everything that depends on them, through every layer:
|
|
||||||
// what a merge rebuilds. Along the code and build edges only: a module *built by* the build machine
|
|
||||||
// is not changed by a new build machine, so a built-by edge orders and gates a plan and never
|
|
||||||
// widens it — the first plan of 2026-10-01 took the whole catalogue along for a controller change.
|
|
||||||
func reachableFrom(moved []string, edges []inventory.Edge) []string {
|
|
||||||
in := map[string]bool{}
|
|
||||||
for _, m := range moved {
|
|
||||||
in[m] = true
|
|
||||||
}
|
|
||||||
for grew := true; grew; {
|
|
||||||
grew = false
|
|
||||||
for _, e := range edges {
|
|
||||||
if e.Kind == inventory.EdgeBuiltBy {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if in[e.To] && !in[e.From] {
|
|
||||||
in[e.From] = true
|
|
||||||
grew = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(in))
|
|
||||||
for m := range in {
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasCycle says whether the tiers' last tier holds modules that still depend on each other.
|
|
||||||
func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
|
||||||
if len(tiers) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
last := map[string]bool{}
|
|
||||||
for _, m := range tiers[len(tiers)-1] {
|
|
||||||
last[m] = true
|
|
||||||
}
|
|
||||||
for _, e := range edges {
|
|
||||||
if last[e.From] && last[e.To] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// planFor is the plan a merge produces: the moved modules and everything reachable from them,
|
|
||||||
// tiered, with the merge it answers.
|
|
||||||
func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inventory.Plan {
|
|
||||||
set := reachableFrom(moved, edges)
|
|
||||||
tiers := tiersOf(set, edges)
|
|
||||||
modules := map[string]*inventory.PlanModule{}
|
|
||||||
for _, name := range set {
|
|
||||||
modules[name] = &inventory.PlanModule{}
|
|
||||||
}
|
|
||||||
return inventory.Plan{
|
|
||||||
ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()),
|
|
||||||
Repository: m.Owner + "/" + m.Repo,
|
|
||||||
Commit: m.Commit,
|
|
||||||
Created: time.Now().UTC(),
|
|
||||||
State: inventory.PlanBuilding,
|
|
||||||
Tiers: tiers,
|
|
||||||
Modules: modules,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// gates is what the next tier needs running from this one: a module of the tier that a later
|
|
||||||
// tier is built by — the runtime dependency — and whose policy rolls it out, must be applied by
|
|
||||||
// the machines running it before the next tier is asked. A base an image stands on need only be
|
|
||||||
// built; a source another module packages need not even be that.
|
|
||||||
func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool) []string {
|
|
||||||
if p.Tier >= len(p.Tiers) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
inTier := map[string]bool{}
|
|
||||||
all := map[string]bool{}
|
|
||||||
for _, tier := range p.Tiers {
|
|
||||||
for _, m := range tier {
|
|
||||||
all[m] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, m := range p.Tiers[p.Tier] {
|
|
||||||
inTier[m] = true
|
|
||||||
}
|
|
||||||
later := map[string]bool{}
|
|
||||||
for _, tier := range p.Tiers[p.Tier+1:] {
|
|
||||||
for _, m := range tier {
|
|
||||||
later[m] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var out []string
|
|
||||||
for _, e := range edges {
|
|
||||||
if later[e.From] && inTier[e.To] && e.Kind == inventory.EdgeBuiltBy && !seen[e.To] && rollsOut(e.To) &&
|
|
||||||
!isBaseOf(e.From, e.To, edges, all) {
|
|
||||||
seen[e.To] = true
|
|
||||||
out = append(out, e.To)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// applied says whether every machine running the module has reported since the module was built.
|
|
||||||
func applied(module string, builtAt time.Time, running []string, reports []inventory.Reported) (bool, []string) {
|
|
||||||
at := map[string]*time.Time{}
|
|
||||||
for _, r := range reports {
|
|
||||||
at[r.Node] = r.At
|
|
||||||
}
|
|
||||||
var waiting []string
|
|
||||||
for _, n := range running {
|
|
||||||
if t := at[n]; t == nil || t.Before(builtAt) {
|
|
||||||
waiting = append(waiting, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return len(waiting) == 0, waiting
|
|
||||||
}
|
|
||||||
|
|
||||||
// askTier asks the build machine for every module of the tier, and marks each asked. A module
|
|
||||||
// the catalogue no longer holds, or whose ask could not be made, is a failure of the plan: a tier
|
|
||||||
// half asked is a tier that will never complete.
|
|
||||||
func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) error {
|
|
||||||
entries, err := inv.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
byName := map[string]inventory.Entry{}
|
|
||||||
for _, e := range entries {
|
|
||||||
byName[e.Manifest.Module] = e
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
for _, name := range p.Tiers[p.Tier] {
|
|
||||||
state := p.Modules[name]
|
|
||||||
if state == nil {
|
|
||||||
state = &inventory.PlanModule{}
|
|
||||||
p.Modules[name] = state
|
|
||||||
}
|
|
||||||
e, known := byName[name]
|
|
||||||
if !known {
|
|
||||||
state.State = "failed"
|
|
||||||
state.Why = "no longer in the catalogue"
|
|
||||||
p.State = inventory.PlanFailed
|
|
||||||
p.Note = name + " is no longer in the catalogue"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
|
||||||
fmt.Printf(" tier %d: ", p.Tier)
|
|
||||||
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, 0); err != nil {
|
|
||||||
state.State = "failed"
|
|
||||||
state.Why = err.Error()
|
|
||||||
p.State = inventory.PlanFailed
|
|
||||||
p.Note = fmt.Sprintf("%s could not be asked for: %v", name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
state.State = "asked"
|
|
||||||
state.AskedAt = &now
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and
|
|
||||||
// advances what that completes. Called from the daemon's take-in of every outcome.
|
|
||||||
func planBuilt(ctx context.Context, open *stores, module, commit, failed string) {
|
|
||||||
inv := open.inventory
|
|
||||||
plans, err := inv.OpenPlans(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("plans: cannot read them: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
for i := range plans {
|
|
||||||
p := &plans[i]
|
|
||||||
if p.Tier >= len(p.Tiers) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inTier := false
|
|
||||||
for _, m := range p.Tiers[p.Tier] {
|
|
||||||
if m == module {
|
|
||||||
inTier = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !inTier {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
state := p.Modules[module]
|
|
||||||
if state == nil {
|
|
||||||
state = &inventory.PlanModule{}
|
|
||||||
p.Modules[module] = state
|
|
||||||
}
|
|
||||||
if failed != "" {
|
|
||||||
state.State = "failed"
|
|
||||||
state.Why = failed
|
|
||||||
p.State = inventory.PlanFailed
|
|
||||||
p.Note = fmt.Sprintf("%s failed to build in tier %d", module, p.Tier)
|
|
||||||
} else {
|
|
||||||
state.State = "built"
|
|
||||||
state.BuiltAt = &now
|
|
||||||
state.Commit = commit
|
|
||||||
}
|
|
||||||
if err := inv.SavePlan(ctx, *p); err != nil {
|
|
||||||
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p.State == inventory.PlanFailed {
|
|
||||||
fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
advancePlans(ctx, open)
|
|
||||||
}
|
|
||||||
|
|
||||||
// advancePlans moves every open plan as far as the facts allow: a tier whose modules are all built
|
|
||||||
// and whose gates are applied gives way to the next; the last tier done is the plan done. Called
|
|
||||||
// after every outcome and on a timer, so a plan waiting on a machine's report moves when it comes.
|
|
||||||
func advancePlans(ctx context.Context, open *stores) {
|
|
||||||
inv := open.inventory
|
|
||||||
plans, err := inv.OpenPlans(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("plans: cannot read them: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(plans) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
edges, err := inv.Dependencies(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("plans: cannot read the dependencies: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rollsOut := func(module string) bool {
|
|
||||||
u, err := inv.UpgradeOf(ctx, module)
|
|
||||||
return err == nil && u.RollOut
|
|
||||||
}
|
|
||||||
for i := range plans {
|
|
||||||
p := &plans[i]
|
|
||||||
for p.Open() {
|
|
||||||
moved, err := advanceOnce(ctx, open, p, edges, rollsOut)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("%s: %v\n", p.ID, err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err := inv.SavePlan(ctx, *p); err != nil {
|
|
||||||
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !moved {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// advanceOnce takes one step of one plan and says whether anything changed.
|
|
||||||
func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
|
||||||
edges []inventory.Edge, rollsOut func(string) bool) (bool, error) {
|
|
||||||
inv := open.inventory
|
|
||||||
if p.Tier >= len(p.Tiers) {
|
|
||||||
p.State = inventory.PlanDone
|
|
||||||
fmt.Printf("%s: done — %s at %s, %d tier(s)\n", p.ID, p.Repository, short(p.Commit), len(p.Tiers))
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
tier := p.Tiers[p.Tier]
|
|
||||||
// Not yet asked: ask.
|
|
||||||
unasked := 0
|
|
||||||
for _, m := range tier {
|
|
||||||
if s := p.Modules[m]; s == nil || s.State == "" {
|
|
||||||
unasked++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if unasked == len(tier) {
|
|
||||||
if err := askTier(ctx, inv, p); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
// Asked: wait for every build.
|
|
||||||
var latest time.Time
|
|
||||||
for _, m := range tier {
|
|
||||||
s := p.Modules[m]
|
|
||||||
if s == nil || s.State != "built" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if s.BuiltAt != nil && s.BuiltAt.After(latest) {
|
|
||||||
latest = *s.BuiltAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Built: send every module of the tier whose policy rolls out, once, to the machines running
|
|
||||||
// it — whether or not its source commit moved. A dependent rebuilt because its base moved, or
|
|
||||||
// a module that packages another repository's source, keeps its commit; the catalogue announces
|
|
||||||
// no move for it and its machines would keep the old image until somebody pushed (novox/hq
|
|
||||||
// issue 189). A module whose policy records is built and left, as its policy says.
|
|
||||||
for _, m := range tier {
|
|
||||||
state := p.Modules[m]
|
|
||||||
if state == nil || state.SentAt != nil || !rollsOut(m) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
running, err := inv.Running(ctx, m)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
state.SentAt = &now
|
|
||||||
if len(running) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := sendTo(ctx, open, running); err != nil {
|
|
||||||
return false, fmt.Errorf("sending %s to %s after tier %d: %w", m, strings.Join(running, ", "), p.Tier, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: tier %d built; sent %s to %s\n", p.ID, p.Tier, m, strings.Join(running, ", "))
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
// And wait for what the next tier needs running.
|
|
||||||
needed := gates(*p, edges, rollsOut)
|
|
||||||
if len(needed) > 0 {
|
|
||||||
reports, err := inv.LastReports(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
var waiting []string
|
|
||||||
for _, m := range needed {
|
|
||||||
running, err := inv.Running(ctx, m)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
state := p.Modules[m]
|
|
||||||
if state == nil {
|
|
||||||
state = &inventory.PlanModule{}
|
|
||||||
p.Modules[m] = state
|
|
||||||
}
|
|
||||||
// The plan sends what it waits for. A rebuild from the same source commit is not a
|
|
||||||
// move the catalogue announces — the build machine rebuilt for a controller change
|
|
||||||
// is one — so the roll-out that opens this gate is the plan's to make, once, and
|
|
||||||
// the reports that open it are the ones after the send.
|
|
||||||
since := latest
|
|
||||||
if state.BuiltAt != nil {
|
|
||||||
since = *state.BuiltAt
|
|
||||||
}
|
|
||||||
if state.SentAt != nil && state.SentAt.After(since) {
|
|
||||||
since = *state.SentAt
|
|
||||||
}
|
|
||||||
if ok, on := applied(m, since, running, reports); !ok {
|
|
||||||
waiting = append(waiting, fmt.Sprintf("%s on %s", m, strings.Join(on, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(waiting) > 0 {
|
|
||||||
note := "tier " + fmt.Sprint(p.Tier) + " built; waiting for " + strings.Join(waiting, "; ") + " to be applied"
|
|
||||||
changed := p.State != inventory.PlanRolling || p.Note != note
|
|
||||||
p.State = inventory.PlanRolling
|
|
||||||
p.Note = note
|
|
||||||
return changed, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.Tier++
|
|
||||||
p.State = inventory.PlanBuilding
|
|
||||||
p.Note = ""
|
|
||||||
if p.Tier < len(p.Tiers) {
|
|
||||||
fmt.Printf("%s: tier %d done; asking tier %d: %s\n", p.ID, p.Tier-1, p.Tier, strings.Join(p.Tiers[p.Tier], ", "))
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planTicker advances open plans on a timer, for the steps outcomes alone cannot take.
|
|
||||||
func planTicker(ctx context.Context, open *stores) {
|
|
||||||
advancePlans(ctx, open)
|
|
||||||
tick := time.NewTicker(30 * time.Second)
|
|
||||||
defer tick.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-tick.C:
|
|
||||||
advancePlans(ctx, open)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// planLine is one plan as `status` says it.
|
|
||||||
func planLine(p inventory.Plan, now time.Time) string {
|
|
||||||
where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers))
|
|
||||||
switch p.State {
|
|
||||||
case inventory.PlanDone:
|
|
||||||
return fmt.Sprintf("%s %s done, %d tier(s)", p.Repository, short(p.Commit), len(p.Tiers))
|
|
||||||
case inventory.PlanFailed:
|
|
||||||
return fmt.Sprintf("%s %s FAILED at %s: %s", p.Repository, short(p.Commit), where, p.Note)
|
|
||||||
}
|
|
||||||
since := now.Sub(p.Updated).Round(time.Minute)
|
|
||||||
late := ""
|
|
||||||
if since > planWaitBound {
|
|
||||||
late = " — LATE"
|
|
||||||
}
|
|
||||||
what := "building"
|
|
||||||
if p.State == inventory.PlanRolling {
|
|
||||||
what = p.Note
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s %s %s, %s for %s%s", p.Repository, short(p.Commit), where, what, since, late)
|
|
||||||
}
|
|
||||||
|
|
||||||
// planFailedBuild marks the module a failed build was for when the result names no module: by the
|
|
||||||
// repository and path the plan's modules were asked at.
|
|
||||||
func planFailedBuild(ctx context.Context, open *stores, result link.BuildResult) {
|
|
||||||
entries, err := open.inventory.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
if repositoryMatches(e.Source.Repository, result.Repository) && e.Source.Path == result.Path {
|
|
||||||
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func repositoryMatches(a, b string) bool {
|
|
||||||
trim := func(s string) string { return strings.ToLower(strings.TrimSuffix(s, ".git")) }
|
|
||||||
return trim(a) == trim(b) || strings.HasSuffix(trim(a), "/"+trim(b)) || strings.HasSuffix(trim(b), "/"+trim(a))
|
|
||||||
}
|
|
||||||
|
|
||||||
// planStatus is one plan as `status --json` says it.
|
|
||||||
type planStatus struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Repository string `json:"repository"`
|
|
||||||
Commit string `json:"commit"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Tier int `json:"tier"`
|
|
||||||
Tiers int `json:"tiers"`
|
|
||||||
Waiting string `json:"waiting,omitempty"`
|
|
||||||
Since time.Time `json:"since"`
|
|
||||||
Late bool `json:"late"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func planStatuses(plans []inventory.Plan, now time.Time) []planStatus {
|
|
||||||
out := make([]planStatus, 0, len(plans))
|
|
||||||
for _, p := range plans {
|
|
||||||
ps := planStatus{ID: p.ID, Repository: p.Repository, Commit: p.Commit, State: p.State,
|
|
||||||
Tier: p.Tier, Tiers: len(p.Tiers), Since: p.Updated}
|
|
||||||
if p.Open() {
|
|
||||||
ps.Waiting = p.Note
|
|
||||||
if ps.Waiting == "" {
|
|
||||||
ps.Waiting = "builds of tier " + fmt.Sprint(p.Tier)
|
|
||||||
}
|
|
||||||
ps.Late = now.Sub(p.Updated) > planWaitBound
|
|
||||||
}
|
|
||||||
out = append(out, ps)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// openPlans is the open plans among the recent ones, and how many have waited past the bound.
|
|
||||||
func openPlans(plans []inventory.Plan) ([]inventory.Plan, int) {
|
|
||||||
var open []inventory.Plan
|
|
||||||
late := 0
|
|
||||||
for _, p := range plans {
|
|
||||||
if p.Open() {
|
|
||||||
open = append(open, p)
|
|
||||||
if time.Since(p.Updated) > planWaitBound {
|
|
||||||
late++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return open, late
|
|
||||||
}
|
|
||||||
|
|
||||||
// plansCommand says what the last merges produced and where each stands; given an id, one plan
|
|
||||||
// tier by tier with every module's state.
|
|
||||||
func plansCommand(ctx context.Context, args []string) error {
|
|
||||||
set := flag.NewFlagSet("plans", flag.ContinueOnError)
|
|
||||||
limit := set.Int("n", 10, "how many to show")
|
|
||||||
whatIf := set.String("what-if", "", "owner/repository: the plan a merge there would produce, saving nothing — with --paths or --modules")
|
|
||||||
paths := set.String("paths", "", "the files the merge would change, comma-separated, from the repository's root")
|
|
||||||
modules := set.String("modules", "", "or the modules it would change, comma-separated")
|
|
||||||
positionals, err := parseAround(set, args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
inv := open.inventory
|
|
||||||
now := time.Now()
|
|
||||||
if len(positionals) == 1 {
|
|
||||||
p, err := inv.PlanByID(ctx, positionals[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("%s — %s\n", p.ID, planLine(p, now))
|
|
||||||
for i, tier := range p.Tiers {
|
|
||||||
marker := " "
|
|
||||||
if i == p.Tier && p.Open() {
|
|
||||||
marker = ">"
|
|
||||||
}
|
|
||||||
fmt.Printf("%s tier %d\n", marker, i)
|
|
||||||
for _, m := range tier {
|
|
||||||
s := p.Modules[m]
|
|
||||||
state := "not yet asked"
|
|
||||||
if s != nil && s.State != "" {
|
|
||||||
state = s.State
|
|
||||||
if s.Commit != "" {
|
|
||||||
state += " from " + short(s.Commit)
|
|
||||||
}
|
|
||||||
if s.Why != "" {
|
|
||||||
state += ": " + s.Why
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" %-22s %s\n", m, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if *whatIf != "" {
|
|
||||||
return planWhatIf(ctx, inv, *whatIf, splitList(*paths), splitList(*modules))
|
|
||||||
}
|
|
||||||
if len(positionals) == 2 && positionals[0] == "stop" {
|
|
||||||
p, err := inv.PlanByID(ctx, positionals[1])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !p.Open() {
|
|
||||||
return fmt.Errorf("%s is already %s", p.ID, p.State)
|
|
||||||
}
|
|
||||||
p.State = inventory.PlanFailed
|
|
||||||
p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier)
|
|
||||||
if err := inv.SavePlan(ctx, p); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("%s stopped at tier %d of %d; what was asked still builds and registers, nothing further is asked\n",
|
|
||||||
p.ID, p.Tier, len(p.Tiers))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
plans, err := inv.RecentPlans(ctx, *limit)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(plans) == 0 {
|
|
||||||
fmt.Println("no merge has produced a plan yet")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, p := range plans {
|
|
||||||
fmt.Printf("%-28s %s\n", p.ID, planLine(p, now))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planWhatIf is the plan a merge would produce, computed the way the merge handler computes one
|
|
||||||
// and saved nowhere: the modules the repository's changed files touch (or the modules named), what
|
|
||||||
// packages their source, everything reachable from them, in tiers. For reading before merging.
|
|
||||||
func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string, paths, modules []string) error {
|
|
||||||
owner, repo, found := strings.Cut(repository, "/")
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("--what-if takes owner/repository, not %q", repository)
|
|
||||||
}
|
|
||||||
m := link.SourceMoved{Owner: owner, Repo: repo, Base: "main", Commit: "what-if", Paths: paths}
|
|
||||||
entries, err := inv.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
read, err := inv.ReadRepositories(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var from, packaging []inventory.Entry
|
|
||||||
named := map[string]bool{}
|
|
||||||
for _, name := range modules {
|
|
||||||
named[name] = true
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
switch {
|
|
||||||
case named[e.Manifest.Module]:
|
|
||||||
from = append(from, e)
|
|
||||||
case len(named) == 0 && sourceIs(e.Source, m):
|
|
||||||
from = append(from, e)
|
|
||||||
case readsFrom(read[e.Manifest.Module], m):
|
|
||||||
packaging = append(packaging, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(named) == 0 {
|
|
||||||
from = whatTheMergeTouched(from, entries, m)
|
|
||||||
}
|
|
||||||
moved := append(append([]inventory.Entry{}, from...), packaging...)
|
|
||||||
if len(moved) == 0 {
|
|
||||||
fmt.Printf("a merge of %s changing %s would build nothing the mesh holds\n", repository,
|
|
||||||
orNone(strings.Join(append(paths, modules...), ", ")))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
edges, err := inv.Dependencies(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
for _, e := range moved {
|
|
||||||
names = append(names, e.Manifest.Module)
|
|
||||||
}
|
|
||||||
p := planOfMerge(m, names, edges)
|
|
||||||
fmt.Printf("a merge of %s would build %d module(s) in %d tier(s):\n", repository, len(p.Modules), len(p.Tiers))
|
|
||||||
rolls := map[string]string{}
|
|
||||||
for i, tier := range p.Tiers {
|
|
||||||
fmt.Printf(" tier %d\n", i)
|
|
||||||
for _, name := range tier {
|
|
||||||
how := "built; its policy records, so nothing is sent"
|
|
||||||
if u, err := inv.UpgradeOf(ctx, name); err == nil && u.RollOut {
|
|
||||||
running, _ := inv.Running(ctx, name)
|
|
||||||
how = "built, then sent to " + orNone(strings.Join(running, ", "))
|
|
||||||
rolls[name] = how
|
|
||||||
}
|
|
||||||
fmt.Printf(" %-22s %s\n", name, how)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasCycle(p.Tiers, edges) {
|
|
||||||
fmt.Println(" the last tier depends on itself and would be built together, in no order")
|
|
||||||
}
|
|
||||||
if len(packaging) > 0 {
|
|
||||||
var also []string
|
|
||||||
for _, e := range packaging {
|
|
||||||
also = append(also, e.Manifest.Module)
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s package source from %s, so they are rebuilt without their own source moving\n",
|
|
||||||
strings.Join(also, ", "), repository)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitList(s string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, part := range strings.Split(s, ",") {
|
|
||||||
if part = strings.TrimSpace(part); part != "" {
|
|
||||||
out = append(out, part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A merge produces a tiered plan (novox/hq ADR 0162): what moved and everything reachable from it,
|
|
||||||
// sorted so a tier depends only on earlier ones — with the three kinds of dependency told apart.
|
|
||||||
func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
|
||||||
edges := []inventory.Edge{
|
|
||||||
// build dependencies: images on the runtime, a plugin on one of them
|
|
||||||
{From: "shop", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
|
||||||
{From: "postgres", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
|
||||||
{From: "shop-plugin", To: "shop", Kind: inventory.EdgeDeclared},
|
|
||||||
// a code dependency: the proxy packages the controller's source — same tier
|
|
||||||
{From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages},
|
|
||||||
{From: "builder", To: "mesh-controller", Kind: inventory.EdgePackages},
|
|
||||||
// runtime dependencies: everything source-built is built by the builder
|
|
||||||
{From: "shop", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "postgres", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "shop-plugin", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "route-proxy", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "mesh-controller", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "mesh-tools", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "builder", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
|
||||||
{From: "unrelated", To: "alpine", Kind: inventory.EdgeStandsOn},
|
|
||||||
}
|
|
||||||
// The runtime image moved: everything on it, and what is built by what is on it.
|
|
||||||
set := reachableFrom([]string{"mesh-tools"}, edges)
|
|
||||||
// What stands on the runtime, and the builder that stands on it; not the controller, which the
|
|
||||||
// builder merely builds, nor the proxy that packages the controller.
|
|
||||||
want := []string{"builder", "mesh-tools", "postgres", "shop", "shop-plugin"}
|
|
||||||
if len(set) != len(want) {
|
|
||||||
t.Fatalf("reachable from the runtime: %v, want %v", set, want)
|
|
||||||
}
|
|
||||||
tiers := tiersOf(set, edges)
|
|
||||||
pos := map[string]int{}
|
|
||||||
for i, tier := range tiers {
|
|
||||||
for _, m := range tier {
|
|
||||||
pos[m] = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pos["mesh-tools"] != 0 || pos["builder"] != 1 {
|
|
||||||
t.Fatalf("the runtime then the builder: %v", tiers)
|
|
||||||
}
|
|
||||||
if !(pos["shop"] > pos["builder"] && pos["postgres"] > pos["builder"]) {
|
|
||||||
t.Fatalf("what the builder builds comes after the builder: %v", tiers)
|
|
||||||
}
|
|
||||||
if pos["shop-plugin"] <= pos["shop"] {
|
|
||||||
t.Fatalf("a plugin after what it is declared on: %v", tiers)
|
|
||||||
}
|
|
||||||
if hasCycle(tiers, edges) {
|
|
||||||
t.Fatalf("no cycle here: %v", tiers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The controller alone moved: the proxy with it, nothing else.
|
|
||||||
small := reachableFrom([]string{"mesh-controller"}, edges)
|
|
||||||
if len(small) != 3 {
|
|
||||||
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
|
|
||||||
}
|
|
||||||
// The builder packages the controller's source (same tier by that edge) and the controller is
|
|
||||||
// built by the builder (next tier by that one): the builder first, then the controller and the
|
|
||||||
// proxy together — a code dependency in one tier, a runtime dependency across tiers.
|
|
||||||
smallTiers := tiersOf(small, edges)
|
|
||||||
if len(smallTiers) != 2 || smallTiers[0][0] != "builder" || len(smallTiers[1]) != 2 {
|
|
||||||
t.Fatalf("the builder, then the controller and the proxy together: %v", smallTiers)
|
|
||||||
}
|
|
||||||
// The builder alone moved: the builder, and nothing it builds.
|
|
||||||
if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 {
|
|
||||||
t.Fatalf("a build machine change rebuilds the build machine alone: %v", only)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a runtime dependency gates on deployment, and only when the module rolls out.
|
|
||||||
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"}, []string{"mesh-tools"}, edges)
|
|
||||||
p.Tier = pos["builder"]
|
|
||||||
rollsOut := func(m string) bool { return m == "builder" }
|
|
||||||
if g := gates(p, edges, rollsOut); len(g) != 1 || g[0] != "builder" {
|
|
||||||
t.Fatalf("the builder gates the tier after it: %v", g)
|
|
||||||
}
|
|
||||||
p.Tier = 0
|
|
||||||
if g := gates(p, edges, rollsOut); len(g) != 0 {
|
|
||||||
t.Fatalf("the runtime image is a build dependency and gates nothing: %v", g)
|
|
||||||
}
|
|
||||||
if g := gates(p, edges, func(string) bool { return false }); len(g) != 0 {
|
|
||||||
t.Fatalf("a module that only records its upgrade gates nothing: %v", g)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A gate is open once every machine running the module has reported after it was built.
|
|
||||||
func TestAGateOpensWhenTheMachinesHaveReportedSinceTheBuild(t *testing.T) {
|
|
||||||
built := time.Date(2026, 10, 1, 15, 0, 0, 0, time.UTC)
|
|
||||||
before, after := built.Add(-time.Minute), built.Add(time.Minute)
|
|
||||||
reports := []inventory.Reported{{Node: "anchor", At: &after}, {Node: "home-server", At: &before}}
|
|
||||||
ok, waiting := applied("builder", built, []string{"anchor", "home-server"}, reports)
|
|
||||||
if ok || len(waiting) != 1 || waiting[0] != "home-server" {
|
|
||||||
t.Fatalf("one machine has not reported since the build: ok=%v waiting=%v", ok, waiting)
|
|
||||||
}
|
|
||||||
if ok, _ := applied("builder", built, []string{"anchor"}, reports); !ok {
|
|
||||||
t.Fatal("the machine that reported after the build holds the gate open")
|
|
||||||
}
|
|
||||||
if ok, _ := applied("builder", built, nil, reports); !ok {
|
|
||||||
t.Fatal("a module running nowhere gates nothing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A cycle is not lost: what remains is one last tier, and the caller says so.
|
|
||||||
func TestACycleIsOneLastTierAndSaidSo(t *testing.T) {
|
|
||||||
edges := []inventory.Edge{{From: "a", To: "b", Kind: inventory.EdgeStandsOn}, {From: "b", To: "a", Kind: inventory.EdgeStandsOn}}
|
|
||||||
tiers := tiersOf([]string{"a", "b"}, edges)
|
|
||||||
if len(tiers) != 1 || len(tiers[0]) != 2 || !hasCycle(tiers, edges) {
|
|
||||||
t.Fatalf("a cycle should be one tier of two, said: %v", tiers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The mesh's own verbs, served as the mesh-controller seat's tools (novox/hq ADR 0154, design 33).
|
|
||||||
//
|
|
||||||
// **Each tool runs the command it names, in this same binary, and answers what it printed.** That is
|
|
||||||
// ADR 0035 taken literally: the logic lives once, in the command, and a surface is an adapter with no
|
|
||||||
// decisions in it. Running a fresh process rather than calling the function keeps two things true
|
|
||||||
// that calling it would not — every command opens and closes its own stores the way it does from a
|
|
||||||
// shell, and nothing a command prints to the process's standard output can leak into another call's
|
|
||||||
// answer. It also means a refusal is the same refusal in the same words, because it is the same
|
|
||||||
// output.
|
|
||||||
|
|
||||||
// verbAnswer is what a verb answers: what the command printed, whether it succeeded, and — where the
|
|
||||||
// command speaks JSON — the same as data.
|
|
||||||
type verbAnswer struct {
|
|
||||||
Output string `json:"output"`
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
Answer any `json:"answer,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// argvFor is the command line a verb and its arguments become. Only the verbs the seat declares, and
|
|
||||||
// only the arguments each declares: a caller cannot reach a flag the schema did not name.
|
|
||||||
func argvFor(verb string, args map[string]any) ([]string, error) {
|
|
||||||
str := func(key string) string {
|
|
||||||
v, _ := args[key].(string)
|
|
||||||
return strings.TrimSpace(v)
|
|
||||||
}
|
|
||||||
need := func(keys ...string) error {
|
|
||||||
for _, k := range keys {
|
|
||||||
if str(k) == "" {
|
|
||||||
return fmt.Errorf("%s needs %q", verb, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch verb {
|
|
||||||
case "status":
|
|
||||||
return []string{"status", "--json"}, nil
|
|
||||||
case "nodes":
|
|
||||||
return []string{"node", "list"}, nil
|
|
||||||
case "node":
|
|
||||||
if err := need("node"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return []string{"node", "show", str("node")}, nil
|
|
||||||
case "modules":
|
|
||||||
return []string{"module", "list"}, nil
|
|
||||||
case "seats":
|
|
||||||
return []string{"seats", "--json"}, nil
|
|
||||||
case "builds":
|
|
||||||
if id := str("log"); id != "" {
|
|
||||||
return []string{"builds", "--log", id}, nil
|
|
||||||
}
|
|
||||||
if m := str("module"); m != "" {
|
|
||||||
return []string{"builds", m}, nil
|
|
||||||
}
|
|
||||||
return []string{"builds"}, nil
|
|
||||||
case "plans":
|
|
||||||
if r := str("repository"); r != "" {
|
|
||||||
argv := []string{"plans", "--what-if", r}
|
|
||||||
if p := str("paths"); p != "" {
|
|
||||||
argv = append(argv, "--paths", p)
|
|
||||||
}
|
|
||||||
if m := str("modules"); m != "" {
|
|
||||||
argv = append(argv, "--modules", m)
|
|
||||||
}
|
|
||||||
return argv, nil
|
|
||||||
}
|
|
||||||
if id := str("stop"); id != "" {
|
|
||||||
return []string{"plans", "stop", id}, nil
|
|
||||||
}
|
|
||||||
if id := str("id"); id != "" {
|
|
||||||
return []string{"plans", id}, nil
|
|
||||||
}
|
|
||||||
return []string{"plans"}, nil
|
|
||||||
case "plan":
|
|
||||||
if err := need("node"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return []string{"plan", str("node"), "--json"}, nil
|
|
||||||
case "assign", "unassign":
|
|
||||||
if err := need("node", "module"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return []string{verb, str("node"), str("module")}, nil
|
|
||||||
case "pin":
|
|
||||||
if err := need("node", "provision", "from", "module"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return []string{"pin", str("node"), str("provision"), str("from"), str("module")}, nil
|
|
||||||
case "unpin":
|
|
||||||
if err := need("node", "provision"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return []string{"unpin", str("node"), str("provision")}, nil
|
|
||||||
case "push":
|
|
||||||
// Sent and not waited for: the asker reads `status` for what the machine did, which is
|
|
||||||
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
|
|
||||||
// time out on every machine that takes a minute, and say nothing about the ones that did not.
|
|
||||||
if n := str("node"); n != "" {
|
|
||||||
return []string{"push", n, "--wait", "0"}, nil
|
|
||||||
}
|
|
||||||
return []string{"push", "--behind", "--wait", "0"}, nil
|
|
||||||
case "rotate":
|
|
||||||
if p := str("provision"); p != "" {
|
|
||||||
argv := []string{"rotate", p}
|
|
||||||
if c := str("consumer"); c != "" {
|
|
||||||
argv = append(argv, "--consumer", c)
|
|
||||||
}
|
|
||||||
return argv, nil
|
|
||||||
}
|
|
||||||
if str("node") != "" && str("module") != "" && str("secret") != "" {
|
|
||||||
return []string{"secret", "rotate", str("node"), str("module"), str("secret")}, nil
|
|
||||||
}
|
|
||||||
// Half of either shape: the command says its usage, which names both shapes, and that is
|
|
||||||
// the answer the caller needs.
|
|
||||||
return []string{"rotate"}, nil
|
|
||||||
case "build":
|
|
||||||
if err := need("repository"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Not waited for: a tool call cannot hold a connection for the minutes a build takes; the
|
|
||||||
// daemon takes the outcome in when it comes and the id follows the build (issue 176). A
|
|
||||||
// repository given without a scheme is a path on the forge holding the git seat.
|
|
||||||
argv := []string{"build", str("repository"), "--wait", "0"}
|
|
||||||
if !strings.Contains(str("repository"), "://") && !strings.HasPrefix(str("repository"), "git@") {
|
|
||||||
argv = append(argv, "--self")
|
|
||||||
}
|
|
||||||
if p := str("path"); p != "" {
|
|
||||||
argv = append(argv, "--path", p)
|
|
||||||
}
|
|
||||||
if r := str("ref"); r != "" {
|
|
||||||
argv = append(argv, "--ref", r)
|
|
||||||
}
|
|
||||||
return argv, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("%q is not a verb the %s seat serves", verb, catalogue.ControllerSeatName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsonVerbs are the verbs whose command speaks JSON, so the answer carries it as data as well.
|
|
||||||
var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true}
|
|
||||||
|
|
||||||
// runVerb runs this binary with the given command line and gathers what it said.
|
|
||||||
func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
|
||||||
self, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return verbAnswer{}, err
|
|
||||||
}
|
|
||||||
cmd := exec.CommandContext(ctx, self, argv...)
|
|
||||||
// The same environment: the stores' credentials, the bus, the broker — everything a command run
|
|
||||||
// from a shell in this container would have, because it is that.
|
|
||||||
cmd.Env = os.Environ()
|
|
||||||
// Two buffers, one answer. What the command *says* is both streams, in the order a person at
|
|
||||||
// a shell would read them; what it *answers as data* is standard output alone — `status --json`
|
|
||||||
// prints its warnings beside the document, and a JSON parsed from the two together parsed
|
|
||||||
// nothing (2026-09-30, the first status asked through the console had no `answer`).
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
runErr := cmd.Run()
|
|
||||||
answer := verbAnswer{Output: stdout.String() + stderr.String(), OK: runErr == nil}
|
|
||||||
if jsonVerbs[argv[0]] && runErr == nil {
|
|
||||||
var parsed any
|
|
||||||
if json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed) == nil {
|
|
||||||
answer.Answer = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var exit *exec.ExitError
|
|
||||||
if runErr != nil && !errors.As(runErr, &exit) {
|
|
||||||
// Not the command refusing — the command not running at all, which is this process's fault.
|
|
||||||
return answer, fmt.Errorf("could not run %s: %w", strings.Join(argv, " "), runErr)
|
|
||||||
}
|
|
||||||
return answer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
|
|
||||||
// store's row, so a verb the row does not carry is not served and a verb it carries that this binary
|
|
||||||
// cannot run is said at start rather than at the first call.
|
|
||||||
func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
|
||||||
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
|
||||||
if !known {
|
|
||||||
return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
|
||||||
}
|
|
||||||
handlers := map[string]link.ToolHandler{}
|
|
||||||
for _, v := range seat.Serves {
|
|
||||||
verb := v.Name
|
|
||||||
if verb == "tools" {
|
|
||||||
handlers[verb] = func(ctx context.Context, _ json.RawMessage) (any, error) {
|
|
||||||
return seatTools(), nil
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
|
||||||
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
|
|
||||||
catalogue.ControllerSeatName, verb, err)
|
|
||||||
}
|
|
||||||
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
|
|
||||||
args := map[string]any{}
|
|
||||||
if len(raw) > 0 {
|
|
||||||
if err := json.Unmarshal(raw, &args); err != nil {
|
|
||||||
return nil, fmt.Errorf("the arguments are not a JSON object: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
argv, err := argvFor(verb, args)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return runVerb(ctx, argv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return handlers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
|
|
||||||
// mesh's own records — no holder in the path, so it is true while a holder restarts (design 33 §5).
|
|
||||||
func seatTools() map[string]any {
|
|
||||||
var seats []map[string]any
|
|
||||||
for _, s := range catalogue.SeatsWithAProtocol() {
|
|
||||||
if len(s.Serves) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var tools []map[string]any
|
|
||||||
for _, v := range s.Serves {
|
|
||||||
tools = append(tools, map[string]any{
|
|
||||||
"name": v.Name, "description": v.Description, "input": v.Input, "output": v.Output,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
seats = append(seats, map[string]any{"seat": s.Name, "scope": s.Scope, "tools": tools})
|
|
||||||
}
|
|
||||||
return map[string]any{"seats": seats}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sampleArguments is one of every argument a verb's schema requires, so the check at start proves the
|
|
||||||
// verb runnable rather than that it happens to want the arguments the check guessed.
|
|
||||||
func sampleArguments(v catalogue.Verb) map[string]any {
|
|
||||||
sample := map[string]any{"node": "x", "module": "x", "repository": "x"}
|
|
||||||
switch required := v.Input["required"].(type) {
|
|
||||||
case []string:
|
|
||||||
for _, k := range required {
|
|
||||||
sample[k] = "x"
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for _, k := range required {
|
|
||||||
if name, ok := k.(string); ok {
|
|
||||||
sample[name] = "x"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sample
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Every verb the mesh-controller seat declares is one this binary can run, with the arguments the
|
|
||||||
// schema names and no other (novox/hq ADR 0154, ADR 0035).
|
|
||||||
func TestEveryDeclaredVerbHasACommandLine(t *testing.T) {
|
|
||||||
for _, v := range catalogue.ControllerVerbs {
|
|
||||||
if v.Name == "tools" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
args := map[string]any{}
|
|
||||||
props, _ := v.Input["properties"].(map[string]any)
|
|
||||||
for name := range props {
|
|
||||||
args[name] = "x"
|
|
||||||
}
|
|
||||||
argv, err := argvFor(v.Name, args)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s: %v", v.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if argv[0] == "" {
|
|
||||||
t.Errorf("%s: empty command", v.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `builds` given a build's id reads that build's log from the bus rather than listing builds
|
|
||||||
// (novox/hq ADR 0157).
|
|
||||||
func TestBuildsWithAnIdReadsThatBuildsLog(t *testing.T) {
|
|
||||||
argv, err := argvFor("builds", map[string]any{"log": "build-17"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if strings.Join(argv, " ") != "builds --log build-17" {
|
|
||||||
t.Fatalf("builds with a log id became %q", strings.Join(argv, " "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The build tool takes a repository as a URL or as its path on the forge holding the git seat, and
|
|
||||||
// says which it was given, so the command reads the path as a seat source rather than handing it to
|
|
||||||
// git as written (novox/hq issue 176). And it never waits: the id follows the build.
|
|
||||||
func TestTheBuildToolTellsAForgePathFromAURL(t *testing.T) {
|
|
||||||
argv, _ := argvFor("build", map[string]any{"repository": "novox/mesh-catalog", "path": "modules/x"})
|
|
||||||
if line := strings.Join(argv, " "); !strings.Contains(line, "--self") || !strings.Contains(line, "--wait 0") {
|
|
||||||
t.Fatalf("a forge path is a seat source, not waited for; got %q", line)
|
|
||||||
}
|
|
||||||
argv, _ = argvFor("build", map[string]any{"repository": "https://example.tld/o/r.git"})
|
|
||||||
if line := strings.Join(argv, " "); strings.Contains(line, "--self") {
|
|
||||||
t.Fatalf("a URL is cloned as given; got %q", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `rotate` is one verb with two shapes (ADR 0114, issue 180): a pair credential by provision, or a
|
|
||||||
// module's own secret by machine, module and name.
|
|
||||||
func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
|
||||||
argv, _ := argvFor("rotate", map[string]any{"provision": "postgres-database", "consumer": "ace"})
|
|
||||||
if strings.Join(argv, " ") != "rotate postgres-database --consumer ace" {
|
|
||||||
t.Fatalf("a pair credential: %v", argv)
|
|
||||||
}
|
|
||||||
argv, _ = argvFor("rotate", map[string]any{"node": "ace", "module": "nodered", "secret": "api-token"})
|
|
||||||
if strings.Join(argv, " ") != "secret rotate ace nodered api-token" {
|
|
||||||
t.Fatalf("an own secret: %v", argv)
|
|
||||||
}
|
|
||||||
argv, _ = argvFor("rotate", map[string]any{"node": "ace"})
|
|
||||||
if strings.Join(argv, " ") != "rotate" {
|
|
||||||
t.Fatalf("half an own secret falls to the command's usage: %v", argv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A required argument missing is refused in the verb's own words, before anything runs.
|
|
||||||
func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
|
|
||||||
if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) {
|
|
||||||
t.Fatalf("node without a machine was accepted: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := argvFor("upgrade", map[string]any{}); err == nil {
|
|
||||||
t.Fatal("a verb the seat does not serve was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A push and a build are sent, not waited for: the asker reads status, or the build's log by its
|
|
||||||
// id, for what happened. A repository given as a forge path is said to be one (issue 176).
|
|
||||||
func TestActsDoNotBlockTheCall(t *testing.T) {
|
|
||||||
argv, _ := argvFor("push", map[string]any{"node": "one"})
|
|
||||||
if strings.Join(argv, " ") != "push one --wait 0" {
|
|
||||||
t.Fatalf("push waits: %v", argv)
|
|
||||||
}
|
|
||||||
argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"})
|
|
||||||
if strings.Join(argv, " ") != "build novox/x --wait 0 --self --path modules/x" {
|
|
||||||
t.Fatalf("build: %v", argv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What `tools` answers is the seats' records, with each verb's schema.
|
|
||||||
func TestToolsAnswersTheSeatsRecords(t *testing.T) {
|
|
||||||
handlers, err := seatToolHandlers()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(handlers) != len(catalogue.ControllerVerbs) {
|
|
||||||
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
|
|
||||||
}
|
|
||||||
answer := seatTools()
|
|
||||||
seats, _ := answer["seats"].([]map[string]any)
|
|
||||||
var found bool
|
|
||||||
for _, s := range seats {
|
|
||||||
if s["seat"] == catalogue.ControllerSeatName {
|
|
||||||
found = true
|
|
||||||
tools, _ := s["tools"].([]map[string]any)
|
|
||||||
if len(tools) != len(catalogue.ControllerVerbs) || tools[0]["input"] == nil {
|
|
||||||
t.Fatalf("the controller seat's tools are not listed in full: %v", tools)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("the mesh-controller seat is not in the listing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A JSON verb's answer is parsed from what the command wrote to standard output alone; a warning it
|
|
||||||
// printed beside the document does not take the document away. The test binary stands in for the
|
|
||||||
// controller: `-test.run` with a name that matches nothing prints `ok` and a warning about no tests.
|
|
||||||
func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
|
||||||
jsonVerbs["-test.run"] = true
|
|
||||||
t.Cleanup(func() { delete(jsonVerbs, "-test.run") })
|
|
||||||
answer, err := runVerb(t.Context(), []string{"-test.run", "TestAnswerEcho", "-test.v"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !answer.OK {
|
|
||||||
t.Fatalf("the command failed: %s", answer.Output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(answer.Output, "PASS") {
|
|
||||||
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
@@ -39,8 +38,6 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "accept":
|
case "accept":
|
||||||
case "rotate":
|
|
||||||
return secretRotate(ctx, args[1:])
|
|
||||||
case "recover":
|
case "recover":
|
||||||
return secretRecover(ctx, args[1:])
|
return secretRecover(ctx, args[1:])
|
||||||
case "export":
|
case "export":
|
||||||
@@ -104,8 +101,7 @@ func secretCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const secretUsage = "secret rotate <node> <module> <name>\n" +
|
const secretUsage = "secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
|
||||||
"secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
|
|
||||||
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||||
"secret export [--out <file>]"
|
"secret export [--out <file>]"
|
||||||
|
|
||||||
@@ -363,59 +359,3 @@ func valueFor(node, module, name, from string) (string, error) {
|
|||||||
return line, nil
|
return line, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// secretRotate makes a module's own secret anew and sends the machine, so the module starts again on
|
|
||||||
// the new value (novox/hq ADR 0114, issue 180). A pair credential rotates with `rotate <provision>`;
|
|
||||||
// this is the secret with one party. Said in the log with who asked and when, never the value.
|
|
||||||
func secretRotate(ctx context.Context, args []string) error {
|
|
||||||
rest, _ := split(args)
|
|
||||||
if len(rest) != 3 {
|
|
||||||
return errors.New(secretUsage)
|
|
||||||
}
|
|
||||||
node, module, name := rest[0], rest[1], rest[2]
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
if err := open.inventory.RotateModuleSecret(ctx, node, module, name); err != nil {
|
|
||||||
var refused inventory.ErrNotRotatable
|
|
||||||
if errors.As(err, &refused) {
|
|
||||||
return fmt.Errorf("not rotated: %s", refused.Why)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("rotated %q of %s on %s at %s, asked by %s; the value is sealed and not shown\n",
|
|
||||||
name, module, node, time.Now().UTC().Format(time.RFC3339), whoAsked())
|
|
||||||
// A shared credential (ADR 0158) has as many holders as the provision has consumers, and all
|
|
||||||
// of them are sent in one act, so no machine is left reading a value the provider no longer takes.
|
|
||||||
machines, err := open.inventory.SharedHolders(ctx, node, module, name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(machines) == 0 {
|
|
||||||
machines = []string{node}
|
|
||||||
}
|
|
||||||
if len(machines) == 1 {
|
|
||||||
fmt.Printf("sending %s, so %s starts again on the new value:\n", node, module)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("shared with every consumer; sending %s together:\n", strings.Join(machines, ", "))
|
|
||||||
}
|
|
||||||
if err := sendTo(ctx, open, machines); err != nil {
|
|
||||||
return fmt.Errorf("%w\n\nThe new value is sealed and not yet delivered; what runs keeps the old "+
|
|
||||||
"one until the machines next apply. Fix the cause and run `push --behind`", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// whoAsked names the caller for the log: the account the command runs as, which for a tool call
|
|
||||||
// through the console is the mesh's own.
|
|
||||||
func whoAsked() string {
|
|
||||||
if u := os.Getenv("SUDO_USER"); u != "" {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
if u := os.Getenv("USER"); u != "" {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
return "the mesh"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,19 +18,9 @@ import (
|
|||||||
// make a machine look out of date for ever, or send something `plan` never showed.
|
// make a machine look out of date for ever, or send something `plan` never showed.
|
||||||
type sendable struct {
|
type sendable struct {
|
||||||
Resources []map[string]any
|
Resources []map[string]any
|
||||||
// Sequence orders this send against every other to the same node: one higher each time, taken
|
|
||||||
// under the node's hold just before the body is made (novox/hq 04-ISSUES/107). Zero is not sent
|
|
||||||
// at all, which a host reads as "no order claimed" — the shape of every declaration before this.
|
|
||||||
Sequence int64
|
|
||||||
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
||||||
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
||||||
Adoption *adoptionEnvelope
|
Adoption *adoptionEnvelope
|
||||||
|
|
||||||
// Received and Mesh are not sent in the declaration. They are what this machine's memberships
|
|
||||||
// are issued with on the bus (novox/hq ADR 0167): each module's received contributions, from
|
|
||||||
// the same composition as its received files, and every machine's private-network address.
|
|
||||||
Received map[string]map[string][]catalogue.Contribution
|
|
||||||
Mesh []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on
|
// adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on
|
||||||
@@ -48,9 +38,6 @@ func (s sendable) Body() ([]byte, error) {
|
|||||||
if s.Adoption != nil {
|
if s.Adoption != nil {
|
||||||
envelope["adoption"] = s.Adoption
|
envelope["adoption"] = s.Adoption
|
||||||
}
|
}
|
||||||
if s.Sequence > 0 {
|
|
||||||
envelope["sequence"] = s.Sequence
|
|
||||||
}
|
|
||||||
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
||||||
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
||||||
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
||||||
|
|||||||
@@ -47,12 +47,7 @@ func composed(t *testing.T, open *stores, node string) sendable {
|
|||||||
// aMesh's laptop with the private network taken off it, so nothing in the declaration is random:
|
// aMesh's laptop with the private network taken off it, so nothing in the declaration is random:
|
||||||
// what changes this string is a change to what a converged machine is sent, which is the thing an
|
// what changes this string is a change to what a converged machine is sent, which is the thing an
|
||||||
// older host would refuse.
|
// older host would refuse.
|
||||||
//
|
const convergedBefore = `{"declaration":1,"resources":[{"content":"hello","id":"hello-web.page","path":"/var/lib/hello-web/index.html","type":"file"},{"hosts":["anchor.internal:10.77.0.1"],"id":"hello-web.server","image":"registry.example/hello@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"hello-web","type":"container"},{"id":"hello-web.served","path":"/var/lib/hello-web","type":"directory"}]}`
|
||||||
// Re-captured 2026-10-01 (novox/hq issue 177): c978aa7 took `hosts` off every container — a
|
|
||||||
// machine's own resolver knows the mesh's names now — and left this string carrying it, so the
|
|
||||||
// guard failed for a day and nothing ran it. A field an older host never sees is the one change
|
|
||||||
// this guard permits; a field it would refuse is the one it exists to catch.
|
|
||||||
const convergedBefore = `{"declaration":1,"resources":[{"content":"hello","id":"hello-web.page","path":"/var/lib/hello-web/index.html","type":"file"},{"id":"hello-web.server","image":"registry.example/hello@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"hello-web","type":"container"},{"id":"hello-web.served","path":"/var/lib/hello-web","type":"directory"}]}`
|
|
||||||
|
|
||||||
func TestAConvergedDeclarationIsByteForByteWhatItWas(t *testing.T) {
|
func TestAConvergedDeclarationIsByteForByteWhatItWas(t *testing.T) {
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A declaration's only identity was the digest of its bytes; the controller already held a per-node
|
|
||||||
// lock and recorded each send, so the order existed and was thrown away at the wire (novox/hq
|
|
||||||
// 04-ISSUES/107).
|
|
||||||
|
|
||||||
func TestASendCarriesItsNumberInsideTheSignedBytes(t *testing.T) {
|
|
||||||
body, err := sendable{Resources: []map[string]any{{"id": "x", "type": "file"}}, Sequence: 7}.Body()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var env map[string]any
|
|
||||||
if err := json.Unmarshal(body, &env); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, _ := env["sequence"].(float64); got != 7 {
|
|
||||||
t.Fatalf("the body carries sequence %v, wanted 7", env["sequence"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnUnnumberedSendIsByteForByteWhatItWasBefore(t *testing.T) {
|
|
||||||
// Zero is not sent at all. A host reads absence as "no order claimed" — the shape of every
|
|
||||||
// declaration before this — so an older host, or the read-only comparison against a machine
|
|
||||||
// sent nothing since sends were numbered, sees exactly the bytes it always saw.
|
|
||||||
body, err := sendable{Resources: []map[string]any{{"id": "x", "type": "file"}}}.Body()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var env map[string]any
|
|
||||||
if err := json.Unmarshal(body, &env); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, present := env["sequence"]; present {
|
|
||||||
t.Fatalf("a send numbered zero put a sequence on the wire: %s", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEachSendToANodeIsOneHigherAndReadable(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
record, err := open.inventory.NodeByName(t.Context(), "anchor")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Sent nothing since numbering existed: what it would be sent is composed with zero, which is
|
|
||||||
// not on the wire, which is what it was actually sent.
|
|
||||||
if n, err := open.inventory.Sequence(t.Context(), record.ID); err != nil || n != 0 {
|
|
||||||
t.Fatalf("a fresh node reads sequence %d, %v", n, err)
|
|
||||||
}
|
|
||||||
first, err := open.inventory.NextSequence(t.Context(), record.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
second, err := open.inventory.NextSequence(t.Context(), record.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if first != 1 || second != 2 {
|
|
||||||
t.Fatalf("two sends were numbered %d and %d", first, second)
|
|
||||||
}
|
|
||||||
// And the read path sees the last one taken, so the comparison composes what was sent.
|
|
||||||
if n, err := open.inventory.Sequence(t.Context(), record.ID); err != nil || n != 2 {
|
|
||||||
t.Fatalf("after two sends the node reads sequence %d, %v", n, err)
|
|
||||||
}
|
|
||||||
// Another node counts on its own.
|
|
||||||
other, err := open.inventory.NodeByName(t.Context(), "laptop")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n, err := open.inventory.NextSequence(t.Context(), other.ID); err != nil || n != 1 {
|
|
||||||
t.Fatalf("a second node's first send was numbered %d, %v", n, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -82,16 +82,6 @@ func cloneFrom(ctx context.Context, source buildSource) (string, error) {
|
|||||||
// serves no scheme or port has nothing to compose from — a default port here would be the forge's
|
// serves no scheme or port has nothing to compose from — a default port here would be the forge's
|
||||||
// address guessed, which is the thing this exists to stop.
|
// address guessed, which is the thing this exists to stop.
|
||||||
func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) {
|
func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) {
|
||||||
base, err := seatBase(world, seatName)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
|
|
||||||
return fmt.Sprintf("%s/%s.git", base, path), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seatBase is `scheme://host:port` of a seat's holder as the mesh reaches it, for cloning.
|
|
||||||
func seatBase(world catalogue.World, seatName string) (string, error) {
|
|
||||||
seat, known := catalogue.SeatNamed(seatName)
|
seat, known := catalogue.SeatNamed(seatName)
|
||||||
if !known || seat.Delivers == "" {
|
if !known || seat.Delivers == "" {
|
||||||
return "", fmt.Errorf("%q is not a seat a repository can live on", seatName)
|
return "", fmt.Errorf("%q is not a seat a repository can live on", seatName)
|
||||||
@@ -104,9 +94,9 @@ func seatBase(world catalogue.World, seatName string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if holder == nil {
|
if holder == nil {
|
||||||
return "", fmt.Errorf("nobody holds the %s seat, so nothing can be cloned from this mesh's "+
|
return "", fmt.Errorf("nobody holds the %s seat, so %s cannot be cloned from this mesh's "+
|
||||||
"forge — assign a module that claims it, or build from the repository's URL without --self",
|
"forge — assign a module that claims it, or build from the repository's URL without --self",
|
||||||
seat.Name)
|
seat.Name, repository)
|
||||||
}
|
}
|
||||||
var provider *catalogue.Provider
|
var provider *catalogue.Provider
|
||||||
for i, p := range world.Offered[seat.Delivers] {
|
for i, p := range world.Offered[seat.Delivers] {
|
||||||
@@ -128,33 +118,8 @@ func seatBase(world catalogue.World, seatName string) (string, error) {
|
|||||||
return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q",
|
return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q",
|
||||||
holder.Module, holder.Node, seat.Name, seat.Delivers)
|
holder.Module, holder.Node, seat.Name, seat.Delivers)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s://%s:%s", scheme, provider.At, port), nil
|
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
|
||||||
}
|
return fmt.Sprintf("%s://%s:%s/%s.git", scheme, provider.At, port, path), nil
|
||||||
|
|
||||||
// seatBases is the clone base of every seat a recipe's context may name, for a build request
|
|
||||||
// (novox/hq ADR 0155). A seat nobody holds is left out rather than refused here: the build may not
|
|
||||||
// name it at all, and if it does the builder refuses with the seat's name.
|
|
||||||
func seatBases(ctx context.Context) map[string]string {
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
shelf, err := open.inventory.Catalogue(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
bases := map[string]string{}
|
|
||||||
for _, seatName := range []string{gitSeat} {
|
|
||||||
if base, err := seatBase(world, seatName); err == nil {
|
|
||||||
bases[seatName] = base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bases
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// servedPort is a served port as text, however the manifest and the node's settings carried it.
|
// servedPort is a served port as text, however the manifest and the node's settings carried it.
|
||||||
|
|||||||
@@ -138,18 +138,6 @@ func printStatus(asked answers) error {
|
|||||||
len(quiet), strings.Join(said, "\n "))
|
len(quiet), strings.Join(said, "\n "))
|
||||||
}
|
}
|
||||||
|
|
||||||
if open, late := openPlans(asked.plans); len(open) > 0 {
|
|
||||||
fmt.Printf("%d plan(s) open", len(open))
|
|
||||||
if late > 0 {
|
|
||||||
fmt.Printf(", %d waiting past %s", late, planWaitBound)
|
|
||||||
}
|
|
||||||
fmt.Println(":")
|
|
||||||
for _, p := range open {
|
|
||||||
fmt.Printf(" %s\n", planLine(p, time.Now()))
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(behind) > 0 {
|
if len(behind) > 0 {
|
||||||
var names []string
|
var names []string
|
||||||
for m := range behind {
|
for m := range behind {
|
||||||
@@ -301,10 +289,7 @@ func firstLine(s string) string {
|
|||||||
// A type of its own rather than a method on the enrolment, because they are unrelated things
|
// A type of its own rather than a method on the enrolment, because they are unrelated things
|
||||||
// arriving on one queue and an implementation of one should not have to say anything about the
|
// arriving on one queue and an implementation of one should not have to say anything about the
|
||||||
// other.
|
// other.
|
||||||
type builds struct {
|
type builds struct{ inv *inventory.Inventory }
|
||||||
inv *inventory.Inventory
|
|
||||||
open *stores
|
|
||||||
}
|
|
||||||
|
|
||||||
// theThreeQuestions reads what anything answering "is the mesh alright" needs.
|
// theThreeQuestions reads what anything answering "is the mesh alright" needs.
|
||||||
//
|
//
|
||||||
@@ -357,10 +342,6 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
}
|
}
|
||||||
out.plans, err = inv.RecentPlans(ctx, 5)
|
|
||||||
if err != nil {
|
|
||||||
return answers{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// And which machines are not running what the mesh would send them. The same question as a
|
// And which machines are not running what the mesh would send them. The same question as a
|
||||||
// module being behind its source, one level down: that one says the catalogue is out of date,
|
// module being behind its source, one level down: that one says the catalogue is out of date,
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A take is a comparison (novox/hq ADR 0163): the preview puts what runs beside what the module
|
|
||||||
// declares, and an older image or a differing file refuses unless named.
|
|
||||||
func TestATakePreviewsTheComparisonAndRefusesWhatIsNotNamed(t *testing.T) {
|
|
||||||
held := []inventory.Held{
|
|
||||||
{ID: "forge.server", Module: "forge", Kind: "container", Target: "forge", Facts: map[string]any{
|
|
||||||
"image": "forge:1.27.3", "image_created": "2026-09-17T10:00:00Z",
|
|
||||||
"declared_image": "forge:1.22.6", "declared_image_created": "2026-08-20T10:00:00Z", "downgrade": true,
|
|
||||||
"networks": map[string]any{"predecessor_default": []any{"office", "db"}},
|
|
||||||
"ports": []any{"3000/tcp>0.0.0.0:3000"}, "declared_ports": []any{"3000:3000"},
|
|
||||||
}},
|
|
||||||
{ID: "forge.config", Module: "forge", Kind: "file", Target: "/etc/forge/app.ini", Kept: "/var/lib/mesh/kept/app.ini",
|
|
||||||
Facts: map[string]any{"differs": true, "difference": []any{"- private scope: local", "+ upstream: public"}}},
|
|
||||||
{ID: "other.server", Module: "other", Kind: "container", Target: "other"},
|
|
||||||
}
|
|
||||||
preview, refusals := comparisonOf(held, "forge", takeOptions{})
|
|
||||||
for _, want := range []string{"runs forge:1.27.3 (made 2026-09-17)", "declares forge:1.22.6 (made 2026-08-20)", "DOWNGRADE",
|
|
||||||
"on the network predecessor_default with office, db", "publishes 3000/tcp>0.0.0.0:3000; the module declares 3000:3000",
|
|
||||||
"- private scope: local", "original kept at /var/lib/mesh/kept/app.ini"} {
|
|
||||||
if !strings.Contains(preview, want) {
|
|
||||||
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(preview, "other") {
|
|
||||||
t.Errorf("another module's held things are in the preview:\n%s", preview)
|
|
||||||
}
|
|
||||||
if len(refusals) != 2 || !strings.Contains(refusals[0], "--downgrade") || !strings.Contains(refusals[1], "--replace /etc/forge/app.ini") {
|
|
||||||
t.Fatalf("the downgrade and the differing file refuse, each naming its override: %v", refusals)
|
|
||||||
}
|
|
||||||
// Named, they pass.
|
|
||||||
if _, refusals := comparisonOf(held, "forge", takeOptions{Downgrade: true, Replace: map[string]bool{"/etc/forge/app.ini": true}}); len(refusals) != 0 {
|
|
||||||
t.Fatalf("named differences still refused: %v", refusals)
|
|
||||||
}
|
|
||||||
if _, refusals := comparisonOf(held, "forge", takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}}); len(refusals) != 0 {
|
|
||||||
t.Fatalf("replace * did not cover the file: %v", refusals)
|
|
||||||
}
|
|
||||||
// A held thing with no facts yet — a host older than this — refuses nothing and says what it can.
|
|
||||||
if preview, refusals := comparisonOf(held, "other", takeOptions{}); len(refusals) != 0 || !strings.Contains(preview, "container other") {
|
|
||||||
t.Fatalf("a factless hold: %q %v", preview, refusals)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -295,31 +295,17 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
"built from\n", m.Owner, m.Repo, m.Base, m.Commit)
|
"built from\n", m.Owner, m.Repo, m.Base, m.Commit)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// A merge produces a plan the mesh keeps (novox/hq ADR 0162): what moved and everything that
|
against, err := inv.BuiltAgainst(ctx)
|
||||||
// depends on it, along the catalogue's one dependency relation, sorted into tiers. The plan is
|
|
||||||
// written before any build is asked; the first tier is asked; this returns. Outcomes advance it.
|
|
||||||
edges, err := inv.Dependencies(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return notNow(err)
|
return notNow(err)
|
||||||
}
|
}
|
||||||
var movedNames []string
|
ordered := orderByBases(moved, against)
|
||||||
for _, e := range moved {
|
names := make([]string, 0, len(ordered))
|
||||||
movedNames = append(movedNames, e.Manifest.Module)
|
for _, e := range ordered {
|
||||||
|
names = append(names, e.Manifest.Module)
|
||||||
}
|
}
|
||||||
plan := planOfMerge(m, movedNames, edges)
|
fmt.Printf("%s/%s merged into %s (%.8s); building %s\n",
|
||||||
if hasCycle(plan.Tiers, edges) {
|
m.Owner, m.Repo, m.Base, m.Commit, strings.Join(names, ", "))
|
||||||
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
|
||||||
strings.Join(plan.Tiers[len(plan.Tiers)-1], ", "))
|
|
||||||
}
|
|
||||||
if err := inv.SavePlan(ctx, plan); err != nil {
|
|
||||||
return notNow(err)
|
|
||||||
}
|
|
||||||
var tiers []string
|
|
||||||
for i, t := range plan.Tiers {
|
|
||||||
tiers = append(tiers, fmt.Sprintf("%d: %s", i, strings.Join(t, ", ")))
|
|
||||||
}
|
|
||||||
fmt.Printf("%s/%s merged into %s (%.8s); plan %s, %d module(s) in %d tier(s)\n %s\n",
|
|
||||||
m.Owner, m.Repo, m.Base, m.Commit, plan.ID, len(plan.Modules), len(plan.Tiers), strings.Join(tiers, "\n "))
|
|
||||||
if len(packaging) > 0 {
|
if len(packaging) > 0 {
|
||||||
var also []string
|
var also []string
|
||||||
for _, e := range packaging {
|
for _, e := range packaging {
|
||||||
@@ -328,11 +314,22 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
fmt.Printf(" %s package source from it, so they are rebuilt and their own source record "+
|
fmt.Printf(" %s package source from it, so they are rebuilt and their own source record "+
|
||||||
"is left where it is\n", strings.Join(also, ", "))
|
"is left where it is\n", strings.Join(also, ", "))
|
||||||
}
|
}
|
||||||
if err := askTier(ctx, inv, &plan); err != nil {
|
var failed []string
|
||||||
return notNow(err)
|
for _, e := range ordered {
|
||||||
|
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||||
|
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, 20*time.Minute); err != nil {
|
||||||
|
fmt.Printf(" %s: %v\n", e.Manifest.Module, err)
|
||||||
|
failed = append(failed, e.Manifest.Module)
|
||||||
|
// A base that failed is a reason to stop: what stands on it would be built against
|
||||||
|
// the old one, and report success (novox/hq 04-ISSUES/131).
|
||||||
|
if standsOn(ordered, e.Manifest.Module, against) {
|
||||||
|
fmt.Printf(" stopping: %s is a base of what was still to build\n", e.Manifest.Module)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := inv.SavePlan(ctx, plan); err != nil {
|
if len(failed) > 0 {
|
||||||
return notNow(err)
|
fmt.Printf("%d of %d not built: %s\n", len(failed), len(ordered), strings.Join(failed, ", "))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -525,37 +522,3 @@ func isHistory(mergedAt string, seen time.Time) bool {
|
|||||||
}
|
}
|
||||||
return at.Before(seen)
|
return at.Before(seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dependentsOf is every catalogued module that stands on one of the moved modules, directly or
|
|
||||||
// through another dependent, and is not itself among them — in the catalogue's order, so the
|
|
||||||
// answer is the same each time. A module standing on nothing that moved is left alone: a merge
|
|
||||||
// rebuilds what it changed and what is built on top of that, not the catalogue.
|
|
||||||
func dependentsOf(moved, entries []inventory.Entry, against map[string][]string) []inventory.Entry {
|
|
||||||
bases := map[string]bool{}
|
|
||||||
for _, e := range moved {
|
|
||||||
bases[e.Manifest.Module] = true
|
|
||||||
}
|
|
||||||
var out []inventory.Entry
|
|
||||||
taken := map[string]bool{}
|
|
||||||
for grew := true; grew; {
|
|
||||||
grew = false
|
|
||||||
for _, e := range entries {
|
|
||||||
name := e.Manifest.Module
|
|
||||||
if bases[name] || taken[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for base := range bases {
|
|
||||||
if standsOnModule(e, base, against) {
|
|
||||||
taken[name] = true
|
|
||||||
out = append(out, e)
|
|
||||||
grew = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range out {
|
|
||||||
bases[e.Manifest.Module] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the mesh issued this proxy, read on the bus (novox/hq ADR 0160, ADR 0167).
|
|
||||||
//
|
|
||||||
// **The proxy is told, not left to work it out.** Its membership carries the routes it is given —
|
|
||||||
// the same contributions its file is written from — and every machine's address on the private
|
|
||||||
// network, which is who may be served an internal name. Read once at connect and followed, so a
|
|
||||||
// route added or a machine joining reaches a running proxy without a restart.
|
|
||||||
|
|
||||||
// credential is the bus account the mesh delivered as this module's own secret named broker.
|
|
||||||
type credential struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Fingerprint string `json:"fingerprint"`
|
|
||||||
Node string `json:"node"`
|
|
||||||
Module string `json:"module"`
|
|
||||||
User string `json:"user"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// followMembership connects with the credential in path and applies every membership the mesh
|
|
||||||
// issues this proxy. It retries the first connection for as long as it takes: a proxy that started
|
|
||||||
// before the bus keeps serving the file, and takes the bus when it answers.
|
|
||||||
func followMembership(path string, held *table, fromBus *atomic.Bool) {
|
|
||||||
for {
|
|
||||||
err := followOnce(path, held, fromBus)
|
|
||||||
if err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("cannot follow this proxy's membership, serving the file meanwhile: %v", err)
|
|
||||||
time.Sleep(30 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func followOnce(path string, held *table, fromBus *atomic.Bool) error {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var cred credential
|
|
||||||
if err := json.Unmarshal(raw, &cred); err != nil {
|
|
||||||
return fmt.Errorf("the broker credential is not one: %w", err)
|
|
||||||
}
|
|
||||||
if cred.Node == "" || cred.Module == "" {
|
|
||||||
return fmt.Errorf("the broker credential names no node or module, so it has no membership")
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []nats.Option{
|
|
||||||
nats.Name(cred.Node + "." + cred.Module),
|
|
||||||
nats.UserInfo(cred.User, cred.Password),
|
|
||||||
// Its own inbox, and nothing wider: every principal is granted `_INBOX.<its user>.>` alone.
|
|
||||||
nats.CustomInboxPrefix("_INBOX." + cred.User),
|
|
||||||
// The bus being restarted is an upgrade, not a reason to stop following.
|
|
||||||
nats.MaxReconnects(-1),
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cred.Fingerprint) != "" {
|
|
||||||
opts = append(opts, nats.Secure(broker.PinnedToFingerprint(cred.Fingerprint)))
|
|
||||||
}
|
|
||||||
conn, err := nats.Connect(cred.URL, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connecting to the bus at %s: %w", broker.BareAddress(cred.URL), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
subject := broker.MembershipSubject(cred.Node, cred.Module)
|
|
||||||
apply := func(body []byte) {
|
|
||||||
var issued broker.Membership
|
|
||||||
if err := json.Unmarshal(body, &issued); err != nil {
|
|
||||||
log.Printf("a membership arrived that is not one: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if took := applyMembership(issued, held); took && !fromBus.Swap(true) {
|
|
||||||
log.Printf("routes now come from this proxy's membership on %s", subject)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Followed first, read second: an issue landing between the two is applied, not missed.
|
|
||||||
if _, err := conn.Subscribe(subject, func(m *nats.Msg) { apply(m.Data) }); err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return fmt.Errorf("cannot follow %s: %w", subject, err)
|
|
||||||
}
|
|
||||||
// The subject-addressed direct get: the one request this account may make of the stream.
|
|
||||||
got, err := conn.Request("$JS.API.DIRECT.GET."+broker.AssignmentsStream+"."+subject, nil, 5*time.Second)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
log.Printf("cannot read the membership issued on %s yet (%v); following it", subject, err)
|
|
||||||
case got.Header.Get("Status") != "" || len(got.Data) == 0:
|
|
||||||
log.Printf("no membership issued on %s yet; serving the file until one is", subject)
|
|
||||||
default:
|
|
||||||
apply(got.Data)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyMembership serves what a membership says, and says whether it said anything about routes.
|
|
||||||
//
|
|
||||||
// A membership with no routes in it is one from a controller older than ADR 0167, and the file stays
|
|
||||||
// the source rather than every route being withdrawn because a field was absent.
|
|
||||||
func applyMembership(issued broker.Membership, held *table) bool {
|
|
||||||
raw, carries := issued.Receives["route"]
|
|
||||||
if !carries {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var contributions []contribution
|
|
||||||
if err := json.Unmarshal(raw, &contributions); err != nil {
|
|
||||||
log.Printf("the routes in this proxy's membership are not contributions, keeping what is served: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
inside, err := sourcesOf(issued.Mesh)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("the mesh in this proxy's membership is unreadable, keeping what is served: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
routes, public := routesOf(contributions)
|
|
||||||
held.set(routes, public)
|
|
||||||
held.setInside(inside)
|
|
||||||
log.Printf("serving %d route(s) from the membership, internal names to %d machine(s): %s",
|
|
||||||
len(routes), len(inside), strings.Join(held.names(), ", "))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
+29
-175
@@ -54,14 +54,12 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/acme"
|
"golang.org/x/crypto/acme"
|
||||||
@@ -198,63 +196,6 @@ type table struct {
|
|||||||
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
|
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
|
||||||
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
|
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
|
||||||
public map[string]bool
|
public map[string]bool
|
||||||
// inside is where a request must come from to be served a name that is only internal: every
|
|
||||||
// machine's address on the private network, as the mesh issued it in this proxy's membership
|
|
||||||
// (novox/hq ADR 0167). Empty until it is issued, and then only the machine itself is inside.
|
|
||||||
inside sources
|
|
||||||
}
|
|
||||||
|
|
||||||
// sources is who may be served an internal name: the private network's addresses as the mesh
|
|
||||||
// issued them. The machine itself is always inside — anything on a machine may call anything on it
|
|
||||||
// (novox/hq ADR 0144) — so loopback needs no entry.
|
|
||||||
type sources []netip.Prefix
|
|
||||||
|
|
||||||
// sourcesOf reads the addresses the mesh issued, each a single address or a range. One that does
|
|
||||||
// not parse is an error, not an entry skipped: the proxy would otherwise serve internal names to
|
|
||||||
// fewer machines than the mesh said, and say nothing.
|
|
||||||
func sourcesOf(mesh []string) (sources, error) {
|
|
||||||
var out sources
|
|
||||||
for _, entry := range mesh {
|
|
||||||
entry = strings.TrimSpace(entry)
|
|
||||||
if prefix, err := netip.ParsePrefix(entry); err == nil {
|
|
||||||
out = append(out, prefix.Masked())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addr, err := netip.ParseAddr(entry)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%q is not an address on the private network", entry)
|
|
||||||
}
|
|
||||||
addr = addr.Unmap()
|
|
||||||
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// holds says whether a request from this remote address came from the mesh or the machine itself.
|
|
||||||
//
|
|
||||||
// **By source, which the mesh's guard deliberately is not** — it names interfaces, because a source
|
|
||||||
// address can be claimed by whoever sends the packet. The proxy cannot see the interface a request
|
|
||||||
// arrived on, and here the claim does not carry: a connection needs its replies, and replies to a
|
|
||||||
// mesh address leave by the tunnel, never back to the claimant.
|
|
||||||
func (s sources) holds(remote string) bool {
|
|
||||||
host := remote
|
|
||||||
if h, _, err := net.SplitHostPort(remote); err == nil {
|
|
||||||
host = h
|
|
||||||
}
|
|
||||||
addr, err := netip.ParseAddr(host)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
addr = addr.Unmap()
|
|
||||||
if addr.IsLoopback() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, prefix := range s {
|
|
||||||
if prefix.Contains(addr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
||||||
@@ -373,41 +314,6 @@ func bareHost(host string) string {
|
|||||||
return strings.ToLower(host)
|
return strings.ToLower(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hiddenFrom says whether this host must look unrouted to a request from this address: it is
|
|
||||||
// only an internal name, and the request did not come from the private network.
|
|
||||||
//
|
|
||||||
// **The proxy is the only way in to a routed endpoint, so it is what makes `internal` true**
|
|
||||||
// (novox/hq ADR 0138, issue 191). It answers public names on the same listeners, so a request from
|
|
||||||
// anywhere can carry any Host header; a name being internal keeps nobody out unless this check does.
|
|
||||||
// Answered exactly as a name that was never routed, so an outsider learns nothing from asking.
|
|
||||||
func (t *table) hiddenFrom(host, remote string) bool {
|
|
||||||
if !t.eligibleForInternalACME(host) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
t.mu.RLock()
|
|
||||||
defer t.mu.RUnlock()
|
|
||||||
return !t.inside.holds(remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setInside replaces who the mesh is, as the membership said.
|
|
||||||
func (t *table) setInside(inside sources) {
|
|
||||||
t.mu.Lock()
|
|
||||||
t.inside = inside
|
|
||||||
t.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// namesSeenFrom is what this proxy says it serves to a request from this address — every routed
|
|
||||||
// name, less the internal-only ones when the request came from outside.
|
|
||||||
func (t *table) namesSeenFrom(remote string) []string {
|
|
||||||
out := []string{}
|
|
||||||
for _, name := range t.names() {
|
|
||||||
if !t.hiddenFrom(name, remote) {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *table) names() []string {
|
func (t *table) names() []string {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
@@ -437,20 +343,7 @@ func run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
held := newTable()
|
held := newTable()
|
||||||
// **The bus first, the file until it has spoken** (novox/hq ADR 0167). The membership carries
|
|
||||||
// the routes and who the mesh is; the file carries the routes alone, so while the proxy reads
|
|
||||||
// it an internal name is served to this machine and to nobody else — refused, never opened.
|
|
||||||
fromBus := &atomic.Bool{}
|
|
||||||
if credential := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")); credential != "" {
|
|
||||||
go followMembership(credential, held, fromBus)
|
|
||||||
} else {
|
|
||||||
log.Printf("MESH_BROKER_FILE is not set: routes come from %s alone, and a name that is only "+
|
|
||||||
"internal is served to this machine alone", path)
|
|
||||||
}
|
|
||||||
read := func() {
|
read := func() {
|
||||||
if fromBus.Load() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
routes, public, err := routesFrom(path)
|
routes, public, err := routesFrom(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kept serving what it had. A file being rewritten is momentarily unreadable, and
|
// Kept serving what it had. A file being rewritten is momentarily unreadable, and
|
||||||
@@ -529,7 +422,19 @@ func run() error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
tlsConfig := publicManager.TLSConfig()
|
tlsConfig := publicManager.TLSConfig()
|
||||||
tlsConfig.GetCertificate = certificateFor(held, tlsConfig.GetCertificate, internalManager)
|
if internalManager != nil {
|
||||||
|
// Dispatched by which authority may certify this name at all — the same question
|
||||||
|
// eligibleForInternalACME already answers, asked once more at handshake time rather than
|
||||||
|
// only when an order is placed, since a cached certificate is served here on every request
|
||||||
|
// and never goes through HostPolicy again.
|
||||||
|
fromPublic, fromInternal := tlsConfig.GetCertificate, internalManager.TLSConfig().GetCertificate
|
||||||
|
tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
if held.eligibleForInternalACME(hello.ServerName) {
|
||||||
|
return fromInternal(hello)
|
||||||
|
}
|
||||||
|
return fromPublic(hello)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: secure,
|
Addr: secure,
|
||||||
@@ -687,33 +592,6 @@ func forThisAuthority(cache, directory string, root []byte) string {
|
|||||||
return filepath.Join(cache, hex.EncodeToString(sum[:])[:16])
|
return filepath.Join(cache, hex.EncodeToString(sum[:])[:16])
|
||||||
}
|
}
|
||||||
|
|
||||||
// certificateFor picks the certificate a handshake is answered with.
|
|
||||||
//
|
|
||||||
// Dispatched by which authority may certify this name at all — the same question
|
|
||||||
// eligibleForInternalACME already answers, asked once more at handshake time rather than only when
|
|
||||||
// an order is placed, since a cached certificate is served here on every request and never goes
|
|
||||||
// through HostPolicy again. And refused, exactly as an unrouted name is, to a client outside the
|
|
||||||
// private network asking for a name that is only internal: the certificate would name it.
|
|
||||||
func certificateFor(held *table, fromPublic func(*tls.ClientHelloInfo) (*tls.Certificate, error),
|
|
||||||
internalManager *autocert.Manager) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
var fromInternal func(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
|
||||||
if internalManager != nil {
|
|
||||||
fromInternal = internalManager.TLSConfig().GetCertificate
|
|
||||||
}
|
|
||||||
return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
if held.eligibleForInternalACME(hello.ServerName) {
|
|
||||||
if hello.Conn != nil && held.hiddenFrom(hello.ServerName, hello.Conn.RemoteAddr().String()) {
|
|
||||||
return nil, fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for",
|
|
||||||
hello.ServerName)
|
|
||||||
}
|
|
||||||
if fromInternal != nil {
|
|
||||||
return fromInternal(hello)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fromPublic(hello)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTable is an empty routing table.
|
// newTable is an empty routing table.
|
||||||
func newTable() *table {
|
func newTable() *table {
|
||||||
return &table{to: map[string][]rule{}}
|
return &table{to: map[string][]rule{}}
|
||||||
@@ -722,9 +600,8 @@ func newTable() *table {
|
|||||||
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
||||||
func handler(held *table) http.Handler {
|
func handler(held *table) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
hidden := held.hiddenFrom(r.Host, r.RemoteAddr)
|
|
||||||
matched, known := held.find(r.Host, r.URL.Path)
|
matched, known := held.find(r.Host, r.URL.Path)
|
||||||
if hidden || !known {
|
if !known {
|
||||||
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
||||||
// are different things, and a proxy that says only "not found" makes an operator go
|
// are different things, and a proxy that says only "not found" makes an operator go
|
||||||
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
||||||
@@ -734,13 +611,13 @@ func handler(held *table) http.Handler {
|
|||||||
// contradiction an operator would have to disbelieve the proxy to get past.
|
// contradiction an operator would have to disbelieve the proxy to get past.
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
if !hidden && held.routed(r.Host) {
|
if held.routed(r.Host) {
|
||||||
fmt.Fprintf(w, "%s is served here, but no route covers %q.\n",
|
fmt.Fprintf(w, "%s is served here, but no route covers %q.\n",
|
||||||
bareHost(r.Host), r.URL.Path)
|
bareHost(r.Host), r.URL.Path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
||||||
r.Host, strings.Join(held.namesSeenFrom(r.RemoteAddr), ", "))
|
r.Host, strings.Join(held.names(), ", "))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,12 +716,6 @@ func boolByte(b bool) byte {
|
|||||||
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and
|
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and
|
||||||
// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached
|
// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached
|
||||||
// only through `internal-name` never appears there.
|
// only through `internal-name` never appears there.
|
||||||
//
|
|
||||||
// **A route may carry either name, or both** (novox/hq ADR 0138). How far an endpoint reaches
|
|
||||||
// decides which names the mesh composes, so an endpoint that reaches only the private network
|
|
||||||
// arrives with an `internal-name` and no `name`. That is a whole route, not a malformed one: it is
|
|
||||||
// served under its internal name and certified by the internal authority. Only a route with
|
|
||||||
// neither name has nothing to be served under (novox/hq issue 191).
|
|
||||||
func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -854,29 +725,17 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
|||||||
if err := json.Unmarshal(raw, &said); err != nil {
|
if err := json.Unmarshal(raw, &said); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
routes, public := routesOf(said.Given)
|
|
||||||
return routes, public, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// routesOf turns what the mesh gave into host → the rules for that host, and which hosts are public
|
|
||||||
// names — the same whether the contributions came in the file or in the membership.
|
|
||||||
func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) {
|
|
||||||
out := map[string][]rule{}
|
out := map[string][]rule{}
|
||||||
public := map[string]bool{}
|
public := map[string]bool{}
|
||||||
for _, c := range contributions {
|
for _, c := range said.Given {
|
||||||
name, _ := c.Values["name"].(string)
|
name, _ := c.Values["name"].(string)
|
||||||
name = strings.TrimSpace(name)
|
if name == "" {
|
||||||
internal, _ := c.Values["internal-name"].(string)
|
|
||||||
internal = strings.TrimSpace(internal)
|
|
||||||
if name == "" && internal == "" {
|
|
||||||
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// What the route is called in a log line: its public name when it has one.
|
host := strings.ToLower(name)
|
||||||
called := name
|
public[host] = true
|
||||||
if called == "" {
|
|
||||||
called = internal
|
|
||||||
}
|
|
||||||
|
|
||||||
made := rule{path: asPath(c.Values["path"])}
|
made := rule{path: asPath(c.Values["path"])}
|
||||||
if p, ok := asWhole(c.Values["priority"]); ok {
|
if p, ok := asWhole(c.Values["priority"]); ok {
|
||||||
@@ -893,7 +752,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
if looksLikeACredential(named) {
|
if looksLikeACredential(named) {
|
||||||
log.Printf("%s on %s declared route %q with a credential in the declaration rather "+
|
log.Printf("%s on %s declared route %q with a credential in the declaration rather "+
|
||||||
"than the name of a secret; the whole route is refused (novox/hq ADR 0108)",
|
"than the name of a secret; the whole route is refused (novox/hq ADR 0108)",
|
||||||
c.From, c.Node, called)
|
c.From, c.Node, name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
users, err := usersFrom(named)
|
users, err := usersFrom(named)
|
||||||
@@ -911,7 +770,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
port, ok := asPort(c.Values["port"])
|
port, ok := asPort(c.Values["port"])
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
||||||
c.From, c.Node, called)
|
c.From, c.Node, name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
||||||
@@ -932,7 +791,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
}
|
}
|
||||||
if scheme != "http" && scheme != "https" {
|
if scheme != "http" && scheme != "https" {
|
||||||
log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+
|
log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+
|
||||||
"nor https; skipped", c.From, c.Node, called, scheme)
|
"nor https; skipped", c.From, c.Node, name, scheme)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made.insecure, _ = c.Values["insecure"].(bool)
|
made.insecure, _ = c.Values["insecure"].(bool)
|
||||||
@@ -943,7 +802,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
bytes, whole := asWhole(asked)
|
bytes, whole := asWhole(asked)
|
||||||
if !whole || bytes <= 0 {
|
if !whole || bytes <= 0 {
|
||||||
log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is "+
|
log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is "+
|
||||||
"not a whole positive number of bytes; skipped", c.From, c.Node, called, asked)
|
"not a whole positive number of bytes; skipped", c.From, c.Node, name, asked)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made.maxRequestBody = int64(bytes)
|
made.maxRequestBody = int64(bytes)
|
||||||
@@ -951,24 +810,19 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
if name != "" {
|
out[host] = append(out[host], made)
|
||||||
host := strings.ToLower(name)
|
|
||||||
out[host] = append(out[host], made)
|
|
||||||
public[host] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// The internal-network name, the same rule under a second host — a predecessor proxy
|
// The internal-network alias, the same rule under a second host — a predecessor proxy
|
||||||
// answered both for one route, as a convenience (reaching a service over the VPN without a
|
// answered both for one route, as a convenience (reaching a service over the VPN without a
|
||||||
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
||||||
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
||||||
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
|
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
|
||||||
// already has. And the only name, when the endpoint reaches no further than the private
|
// already has.
|
||||||
// network.
|
if internal, _ := c.Values["internal-name"].(string); strings.TrimSpace(internal) != "" {
|
||||||
if internal != "" {
|
|
||||||
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
|
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, public
|
return out, public, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// asWhole is any whole number the mesh wrote, whatever its magnitude.
|
// asWhole is any whole number the mesh wrote, whatever its magnitude.
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// behind is a workload the proxy can send to, and a table routing one public name and one
|
|
||||||
// internal-only name to it, with the mesh's machines as the membership would issue them.
|
|
||||||
func behind(t *testing.T, mesh ...string) *table {
|
|
||||||
t.Helper()
|
|
||||||
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
io.WriteString(w, "the workload")
|
|
||||||
}))
|
|
||||||
t.Cleanup(workload.Close)
|
|
||||||
at, _ := url.Parse(workload.URL)
|
|
||||||
host, port, _ := net.SplitHostPort(at.Host)
|
|
||||||
|
|
||||||
routes, public, err := routesFrom(write(t, fmt.Sprintf(`{"given":[
|
|
||||||
{"from":"app","node":"anchor","at":%q,
|
|
||||||
"values":{"name":"app.example","internal-name":"app.anchor.internal","port":%s}},
|
|
||||||
{"from":"admin","node":"anchor","at":%q,
|
|
||||||
"values":{"internal-name":"admin.anchor.internal","port":%s}}
|
|
||||||
]}`, host, port, host, port)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
held := newTable()
|
|
||||||
inside, err := sourcesOf(mesh)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
held.setInside(inside)
|
|
||||||
held.set(routes, public)
|
|
||||||
return held
|
|
||||||
}
|
|
||||||
|
|
||||||
// askFrom is what the proxy answers a request for host coming from remote.
|
|
||||||
func askFrom(held *table, host, remote string) (int, string) {
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil)
|
|
||||||
r.RemoteAddr = remote
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler(held).ServeHTTP(w, r)
|
|
||||||
return w.Code, w.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// **An internal-only name is served to the private network and to nobody else** (novox/hq ADR
|
|
||||||
// 0138, issue 191). The proxy answers public names on the same listeners, so without this a name
|
|
||||||
// being internal kept nobody out: a request from the internet only had to carry it.
|
|
||||||
func TestAnInternalOnlyNameIsServedOnlyInsideThePrivateNetwork(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.1", "10.10.0.7")
|
|
||||||
|
|
||||||
if code, body := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK ||
|
|
||||||
body != "the workload" {
|
|
||||||
t.Errorf("a request from the private network was not served: %d %q", code, body)
|
|
||||||
}
|
|
||||||
if code, body := askFrom(held, "admin.anchor.internal", "127.0.0.1:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("a request from the machine itself was not served: %d %q", code, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
code, body := askFrom(held, "admin.anchor.internal", "203.0.113.9:51000")
|
|
||||||
if code != http.StatusNotFound {
|
|
||||||
t.Fatalf("a request from outside the private network reached an internal-only name: %d %q",
|
|
||||||
code, body)
|
|
||||||
}
|
|
||||||
// Answered as a name never routed, and the list of what is served does not name it either —
|
|
||||||
// otherwise the refusal would tell an outsider exactly what to ask for from inside.
|
|
||||||
if strings.Contains(strings.SplitN(body, "\n", 2)[1], "admin.anchor.internal") {
|
|
||||||
t.Errorf("the refusal names the internal-only route to an outsider: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "app.example") {
|
|
||||||
t.Errorf("the refusal stopped listing the public names: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The internal name of a route that also has a public one is internal too: served inside, and to
|
|
||||||
// an outsider only under the public name. Nothing is lost — the outsider has the public name — and a
|
|
||||||
// name stays one thing whichever route it came from.
|
|
||||||
func TestAnInternalAliasOfAPublicRouteIsServedInsideOnly(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.1", "10.10.0.7")
|
|
||||||
if code, body := askFrom(held, "app.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("the internal alias stopped answering the private network: %d %q", code, body)
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "app.anchor.internal", "203.0.113.9:51000"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("the internal alias was served to an outsider: %d", code)
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "app.example", "203.0.113.9:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("the public name was refused to an outsider: %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before a membership has said who the mesh is, only the machine itself is inside — refused to
|
|
||||||
// everyone else, never served to everyone.
|
|
||||||
func TestUntilTheMeshIsIssuedAnInternalOnlyNameIsServedToTheMachineAlone(t *testing.T) {
|
|
||||||
held := behind(t)
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("an internal-only name was served with no private network said: %d", code)
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "[::1]:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("an internal-only name was refused to the machine itself: %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type from struct {
|
|
||||||
net.Conn
|
|
||||||
remote net.Addr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c from) RemoteAddr() net.Addr { return c.remote }
|
|
||||||
|
|
||||||
// The handshake refuses an internal-only name to an outsider too: the certificate would name it,
|
|
||||||
// and serving it would answer the question the routing refuses to.
|
|
||||||
func TestTheHandshakeRefusesAnInternalOnlyNameToAnOutsider(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.1", "10.10.0.7")
|
|
||||||
served := &tls.Certificate{}
|
|
||||||
pick := certificateFor(held, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return served, nil }, nil)
|
|
||||||
hello := func(name, remote string) *tls.ClientHelloInfo {
|
|
||||||
addr, _ := net.ResolveTCPAddr("tcp", remote)
|
|
||||||
return &tls.ClientHelloInfo{ServerName: name, Conn: from{remote: addr}}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := pick(hello("admin.anchor.internal", "203.0.113.9:443")); err == nil {
|
|
||||||
t.Error("an outsider was handed a certificate for an internal-only name")
|
|
||||||
}
|
|
||||||
if got, err := pick(hello("admin.anchor.internal", "10.10.0.7:443")); err != nil || got != served {
|
|
||||||
t.Errorf("a client on the private network was refused: %v", err)
|
|
||||||
}
|
|
||||||
if got, err := pick(hello("app.example", "203.0.113.9:443")); err != nil || got != served {
|
|
||||||
t.Errorf("a public name was refused to an outsider: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mesh is issued as machines' addresses; a range is read as well. One that does not parse is
|
|
||||||
// refused rather than skipped, so a typo never quietly narrows or widens who is inside.
|
|
||||||
func TestTheMeshIsReadAsAddressesAndRanges(t *testing.T) {
|
|
||||||
if _, err := sourcesOf([]string{"10.10.0.1", "not-an-address"}); err == nil {
|
|
||||||
t.Error("an entry that is not an address was accepted")
|
|
||||||
}
|
|
||||||
inside, err := sourcesOf([]string{"10.10.0.1", "fd00::1", "10.20.0.0/24"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for remote, want := range map[string]bool{
|
|
||||||
"10.10.0.1:1": true,
|
|
||||||
"[::ffff:10.10.0.1]:1": true,
|
|
||||||
"[fd00::1]:1": true,
|
|
||||||
"10.20.0.200:1": true,
|
|
||||||
"10.10.0.2:1": false,
|
|
||||||
"192.168.1.10:1": false,
|
|
||||||
"not-an-address": false,
|
|
||||||
} {
|
|
||||||
if inside.holds(remote) != want {
|
|
||||||
t.Errorf("%s inside the mesh: got %v, want %v", remote, !want, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the mesh issues is what is served: the routes in the membership, internal names to the
|
|
||||||
// machines it names (novox/hq ADR 0167).
|
|
||||||
func TestAMembershipIsServedAsIssued(t *testing.T) {
|
|
||||||
held := newTable()
|
|
||||||
took := applyMembership(broker.Membership{
|
|
||||||
Receives: map[string]json.RawMessage{"route": json.RawMessage(`[
|
|
||||||
{"from":"admin","node":"anchor","at":"anchor.internal",
|
|
||||||
"values":{"internal-name":"admin.anchor.internal","port":8080}}]`)},
|
|
||||||
Mesh: []string{"10.10.0.7"},
|
|
||||||
}, held)
|
|
||||||
if !took {
|
|
||||||
t.Fatal("a membership carrying routes was not applied")
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound {
|
|
||||||
t.Error("a machine the membership names was refused the internal-only route")
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.9:1"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("a machine the membership does not name was served the internal-only route: %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A membership that says nothing about routes is one from a controller that does not issue them,
|
|
||||||
// and changes nothing: the file stays the source rather than every route being withdrawn.
|
|
||||||
func TestAMembershipWithoutRoutesLeavesTheFileServing(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.7")
|
|
||||||
before := held.names()
|
|
||||||
if applyMembership(broker.Membership{Mesh: []string{"10.10.0.7"}}, held) {
|
|
||||||
t.Error("a membership without routes was taken as the source of routes")
|
|
||||||
}
|
|
||||||
if got := held.names(); strings.Join(got, ",") != strings.Join(before, ",") {
|
|
||||||
t.Errorf("a membership without routes changed what is served: %v, was %v", got, before)
|
|
||||||
}
|
|
||||||
if applyMembership(broker.Membership{
|
|
||||||
Receives: map[string]json.RawMessage{"route": json.RawMessage(`[]`)},
|
|
||||||
Mesh: []string{"not-an-address"},
|
|
||||||
}, held) {
|
|
||||||
t.Error("a membership whose mesh cannot be read was applied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,50 +90,6 @@ func TestARouteWithAnInternalNameIsReachableUnderBoth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A route whose endpoint reaches only the private network carries an internal name and no public
|
|
||||||
// one (novox/hq ADR 0138), and is served under that name rather than skipped as naming nothing —
|
|
||||||
// skipping it left every internal-only module unreachable by name (novox/hq issue 191).
|
|
||||||
func TestARouteWithOnlyAnInternalNameIsServed(t *testing.T) {
|
|
||||||
routes, public, err := routesFrom(write(t, `{"given":[
|
|
||||||
{"from":"app","node":"anchor","at":"anchor.internal",
|
|
||||||
"values":{"internal-name":"App.Anchor.Internal","port":8443,"scheme":"https","insecure":true}}
|
|
||||||
]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if targetOf(routes, "app.anchor.internal") != "https://anchor.internal:8443" {
|
|
||||||
t.Fatalf("the internal-only route is not served: %v", routes)
|
|
||||||
}
|
|
||||||
if len(routes) != 1 {
|
|
||||||
t.Errorf("an internal-only route made hosts it never named: %v", routes)
|
|
||||||
}
|
|
||||||
if len(public) != 0 {
|
|
||||||
t.Errorf("an internal-only route made a name eligible for a public certificate: %v", public)
|
|
||||||
}
|
|
||||||
|
|
||||||
held := newTable()
|
|
||||||
held.set(routes, public)
|
|
||||||
if err := onlyInternalNamesTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err != nil {
|
|
||||||
t.Errorf("the internal authority refused the internal-only route's name: %v", err)
|
|
||||||
}
|
|
||||||
if err := onlyWhatTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err == nil {
|
|
||||||
t.Error("a public certificate was ordered for an internal-only name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A route with neither name has nothing to be served under, and is still skipped.
|
|
||||||
func TestARouteWithNeitherNameIsSkipped(t *testing.T) {
|
|
||||||
routes, public, err := routesFrom(write(t, `{"given":[
|
|
||||||
{"from":"app","node":"anchor","at":"anchor.internal","values":{"internal-name":" ","port":8080}}
|
|
||||||
]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routes) != 0 || len(public) != 0 {
|
|
||||||
t.Errorf("a route that named nothing was served: %v %v", routes, public)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A route with no internal-name composed gets no second host — the ordinary case, unchanged.
|
// A route with no internal-name composed gets no second host — the ordinary case, unchanged.
|
||||||
func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) {
|
func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) {
|
||||||
routes, _, err := routesFrom(write(t, `{"given":[
|
routes, _, err := routesFrom(write(t, `{"given":[
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func TestTheAgreementCheckCatchesASubscriptionThatMatchesNothing(t *testing.T) {
|
|||||||
// An event published under a seat's name is real even though no module declares it as its own.
|
// An event published under a seat's name is real even though no module declares it as its own.
|
||||||
if bad := Disagreements(nil,
|
if bad := Disagreements(nil,
|
||||||
[]AConsumer{{Module: "watcher", Consumes: []string{"mesh-artifact-store.image.pushed"}}},
|
[]AConsumer{{Module: "watcher", Consumes: []string{"mesh-artifact-store.image.pushed"}}},
|
||||||
[]DeclaredSeat{{Name: "mesh-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
|
[]DeclaredSeat{{Name: "the-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
|
||||||
t.Fatalf("an event a seat emits was reported as matching nothing: %v", bad)
|
t.Fatalf("an event a seat emits was reported as matching nothing: %v", bad)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,14 +40,7 @@ type Consumer struct {
|
|||||||
AckWaitSeconds int
|
AckWaitSeconds int
|
||||||
// MaxDeliver before the message is dead-lettered; zero for the mesh's default.
|
// MaxDeliver before the message is dead-lettered; zero for the mesh's default.
|
||||||
MaxDeliver int
|
MaxDeliver int
|
||||||
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
|
Why string
|
||||||
// the server's default, which is many. **One, for a consumer handled one at a time**
|
|
||||||
// (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a
|
|
||||||
// heartbeat, but everything handed over behind it times out unacknowledged and comes back —
|
|
||||||
// and a merge that came back rebuilt what it had just built, five times over on 2026-09-30.
|
|
||||||
// With one outstanding, the server holds the rest, and the heartbeat is keeping the message.
|
|
||||||
MaxAckPending int
|
|
||||||
Why string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than
|
// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than
|
||||||
@@ -161,15 +154,8 @@ func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool)
|
|||||||
Queue: "holders",
|
Queue: "holders",
|
||||||
AckWaitSeconds: 60,
|
AckWaitSeconds: 60,
|
||||||
MaxDeliver: 5,
|
MaxDeliver: 5,
|
||||||
// **One in flight.** A holder works one ask at a time, so the server hands it one at a
|
|
||||||
// time: with the default of many, every ask behind the one being worked was delivered,
|
|
||||||
// left unacknowledged for the length of the work, redelivered after the ack wait, and
|
|
||||||
// after the fifth time dropped — on 2026-10-01 twenty-six of forty-three builds asked in
|
|
||||||
// two minutes were never built, and the queue read as empty (novox/hq issue 186).
|
|
||||||
MaxAckPending: 1,
|
|
||||||
Why: fmt.Sprintf("%s on %s holds %s; it acknowledges after the work is done, so a "+
|
Why: fmt.Sprintf("%s on %s holds %s; it acknowledges after the work is done, so a "+
|
||||||
"crash mid-work redelivers rather than loses; one in flight, so a queue of asks is a "+
|
"crash mid-work redelivers rather than loses", module, node, seat.Name),
|
||||||
"queue and not a race against the ack wait", module, node, seat.Name),
|
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,16 +153,3 @@ func TestANodesDeclarationConsumerIsWhatItsOwnGrantAllows(t *testing.T) {
|
|||||||
has(t, perms.Publish, "$JS.ACK.NODES."+c.Name+".>")
|
has(t, perms.Publish, "$JS.ACK.NODES."+c.Name+".>")
|
||||||
has(t, perms.Subscribe, c.Filters[0])
|
has(t, perms.Subscribe, c.Filters[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// A holder works one ask at a time, so the server hands it one at a time (novox/hq issue 186):
|
|
||||||
// asks queued behind the one being worked wait in the stream rather than being delivered,
|
|
||||||
// left to expire and dropped after the fifth redelivery.
|
|
||||||
func TestAHoldersWorkerTakesOneAskAtATime(t *testing.T) {
|
|
||||||
c, found := HolderConsumerFor("anchor", "builder", DeclaredSeat{Name: "mesh-build-machine", Accepts: []string{"build"}})
|
|
||||||
if !found {
|
|
||||||
t.Fatal("a seat that accepts work has no worker")
|
|
||||||
}
|
|
||||||
if c.MaxAckPending != 1 {
|
|
||||||
t.Fatalf("the worker may have %d asks in flight; one, so a queue is a queue", c.MaxAckPending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module that says it calls a tool may publish exactly that subject (novox/hq ADR 0152): the same
|
|
||||||
// grant a person gets, derived the same way, so one list answers "what may this ask" for everybody.
|
|
||||||
func TestAModuleMayAskOnlyTheToolsItInvokes(t *testing.T) {
|
|
||||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console",
|
|
||||||
Invokes: []string{"shop.price"}, PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, perms.Publish, "mesh.mod.shop.tool.price")
|
|
||||||
hasNot(t, perms.Publish, "mesh.mod.shop.tool.refund")
|
|
||||||
hasNot(t, perms.Publish, "mesh.mod.*.tool.>")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The console's grant: every tool, as one subject, and it reads as one.
|
|
||||||
func TestAModuleInvokingEverythingMayAskAnyTool(t *testing.T) {
|
|
||||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console",
|
|
||||||
Invokes: []string{"*"}, PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, perms.Publish, "mesh.mod.*.tool.>")
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A grant to call widens nothing else.** A module that invokes may not publish an event it did not
|
|
||||||
// declare, may not answer as another module, and subscribes nothing it did not consume — the
|
|
||||||
// difference between the console and a person is that the console is on a machine, not that it may
|
|
||||||
// do more.
|
|
||||||
func TestInvokingGrantsNothingButTheCall(t *testing.T) {
|
|
||||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console",
|
|
||||||
Invokes: []string{"*"}, PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, p := range perms.Publish {
|
|
||||||
if strings.Contains(p, ".event.") {
|
|
||||||
t.Errorf("a module that only invokes may publish %q, an event it never declared", p)
|
|
||||||
}
|
|
||||||
// A role's tools are tools (ADR 0132); a role's work queue and events are not.
|
|
||||||
if strings.HasPrefix(p, "mesh.seat.") && !strings.Contains(p, ".tool.") {
|
|
||||||
t.Errorf("a module that only invokes may publish %q, a seat it neither holds nor uses", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, s := range perms.Subscribe {
|
|
||||||
if strings.Contains(s, ".tool.") && !strings.HasPrefix(s, "mesh.mod.mesh-console.") {
|
|
||||||
t.Errorf("a module that invokes may subscribe %q, another module's tools", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module that declares no invokes calls nothing, which is every module but the console.
|
|
||||||
func TestAModuleThatInvokesNothingCallsNothing(t *testing.T) {
|
|
||||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
|
|
||||||
Emits: []string{"order.placed"}, PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, p := range perms.Publish {
|
|
||||||
if strings.Contains(p, ".tool.") {
|
|
||||||
t.Errorf("a module with no invokes may publish %q", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The malformed entry is refused for a module as it is for a person, and in the same words.
|
|
||||||
func TestAModulesToolGrantThatNamesNoToolIsRefused(t *testing.T) {
|
|
||||||
if _, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
|
|
||||||
Invokes: []string{"telegram"}, PasswordHash: "x"}); err == nil {
|
|
||||||
t.Fatal("a grant naming a module but no tool was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What a declaration says reaches the composed user, so a manifest's `invokes` is the grant.
|
|
||||||
func TestADeclaredInvokeReachesTheComposedUser(t *testing.T) {
|
|
||||||
users, err := Users(Records{
|
|
||||||
Nodes: []string{"desk"},
|
|
||||||
Assigned: map[string][]Declared{"desk": {{Module: "mesh-console", Invokes: []string{"*"}}}},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
perms, err := PermissionsFor(users[len(users)-1])
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, perms.Publish, "mesh.mod.*.tool.>")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module's tool is addressed two ways (novox/hq ADR 0159): to whichever instance answers, and to
|
|
||||||
// the instance on one machine. A grant for the tool covers both and nothing wider.
|
|
||||||
func TestInvokingAToolMayAddressTheMachineToo(t *testing.T) {
|
|
||||||
got, err := invokedSubjects([]string{"postgres.postgres_query"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := []string{"mesh.mod.postgres.tool.postgres_query", "mesh.mod.postgres.tool.postgres_query.*"}
|
|
||||||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
|
||||||
t.Fatalf("the grant is %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -144,7 +144,6 @@ func (j *JetStream) EnsureStream(s Stream) error {
|
|||||||
MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject),
|
MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject),
|
||||||
Description: s.Why,
|
Description: s.Why,
|
||||||
}
|
}
|
||||||
want.AllowDirect = s.Direct
|
|
||||||
if s.Retention == RetentionLastPerSubject {
|
if s.Retention == RetentionLastPerSubject {
|
||||||
// Last-per-subject is a limits stream with one message kept per subject, not a
|
// Last-per-subject is a limits stream with one message kept per subject, not a
|
||||||
// retention policy of its own — the state shape, spelled the way the server spells it.
|
// retention policy of its own — the state shape, spelled the way the server spells it.
|
||||||
@@ -180,7 +179,6 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
|
|||||||
AckPolicy: nats.AckExplicitPolicy,
|
AckPolicy: nats.AckExplicitPolicy,
|
||||||
AckWait: time.Duration(c.AckWaitSeconds) * time.Second,
|
AckWait: time.Duration(c.AckWaitSeconds) * time.Second,
|
||||||
MaxDeliver: c.MaxDeliver,
|
MaxDeliver: c.MaxDeliver,
|
||||||
MaxAckPending: c.MaxAckPending,
|
|
||||||
DeliverGroup: c.Queue,
|
DeliverGroup: c.Queue,
|
||||||
DeliverSubject: "",
|
DeliverSubject: "",
|
||||||
Description: c.Why,
|
Description: c.Why,
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the mesh issues an assignment to serve and to reach (novox/hq ADR 0160).
|
|
||||||
//
|
|
||||||
// A module's code names its tools and its events; **where they land is the mesh's to decide**, and
|
|
||||||
// it decided it twice — once in the runtime, once here, by one rule compiled into both. Now the
|
|
||||||
// controller composes a membership for every module on every machine and publishes it to a subject
|
|
||||||
// only that assignment reads; the runtime serves exactly what the membership says, and the account's
|
|
||||||
// grant is the same composition read the other way. The shape issued today is the shape the mesh
|
|
||||||
// already had, so nothing moves when a membership first arrives; only who decides it moves.
|
|
||||||
|
|
||||||
// Membership is one assignment's subjects: what this instance of a module on this machine serves,
|
|
||||||
// and what it may reach.
|
|
||||||
type Membership struct {
|
|
||||||
Node string `json:"node"`
|
|
||||||
Module string `json:"module"`
|
|
||||||
// Serves is every address a tool of this instance answers on. `{tool}` stands for the tool's
|
|
||||||
// own name, which the module knows and the mesh does not need to: the mesh issues the address,
|
|
||||||
// the runtime fills the name. An address with a queue is shared with the module's other
|
|
||||||
// instances, and the bus hands each call to one of them; an address without is this instance's.
|
|
||||||
Serves []Served `json:"serves"`
|
|
||||||
// Seats is every verb of a seat this instance holds, at the subject the seat's callers use.
|
|
||||||
Seats []SeatServed `json:"seats,omitempty"`
|
|
||||||
// Emits is where an event of this module lands; `{event}` stands for the event's name.
|
|
||||||
Emits string `json:"emits"`
|
|
||||||
// Reaches is each tool this module may call, `<module>.<tool>`, to the subjects that reach it:
|
|
||||||
// the first is whichever instance answers, when the mesh issued one; the rest name a machine.
|
|
||||||
Reaches map[string][]string `json:"reaches,omitempty"`
|
|
||||||
// Tools is where this instance answers what it serves — the runtime's one verb of its own.
|
|
||||||
Tools string `json:"tools"`
|
|
||||||
// Receives is what this assignment is given for each requirement it receives, by requirement:
|
|
||||||
// the contributions of every module that asked for it, as the catalogue composed them (novox/hq
|
|
||||||
// ADR 0167). The same list its received file is written from, so the two cannot disagree; a
|
|
||||||
// requirement nobody contributed to is an empty list, never absent. Kept as JSON because the
|
|
||||||
// catalogue owns the shape of a contribution and the bus only carries it.
|
|
||||||
Receives map[string]json.RawMessage `json:"receives,omitempty"`
|
|
||||||
// Mesh is every machine's address on the private network — what a rule saying "from the mesh"
|
|
||||||
// resolves to in the packet filter, issued here from the same list (novox/hq ADR 0167). A
|
|
||||||
// module that must tell the mesh from the world, the route proxy serving an internal name, reads
|
|
||||||
// it here rather than keeping a definition of its own.
|
|
||||||
Mesh []string `json:"mesh,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Served is one address a tool is answered on.
|
|
||||||
type Served struct {
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Queue string `json:"queue,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeatServed is one verb of a held seat, where its callers ask.
|
|
||||||
type SeatServed struct {
|
|
||||||
Seat string `json:"seat"`
|
|
||||||
Verb string `json:"verb"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MembershipSubject is the one address a runtime derives for itself: where its own membership is
|
|
||||||
// published, from the two names its credential carries. Everything else is in the membership.
|
|
||||||
func MembershipSubject(node, module string) string {
|
|
||||||
return "mesh.assignment." + node + "." + module
|
|
||||||
}
|
|
||||||
|
|
||||||
// Placements is where every module runs, for deciding which instance answers for the module.
|
|
||||||
type Placements struct {
|
|
||||||
// Nodes is each module's machines.
|
|
||||||
Nodes map[string][]string
|
|
||||||
// Interchangeable is each module whose definition says its instances are the same anywhere,
|
|
||||||
// so the module's plain subject is issued to all of them in one queue.
|
|
||||||
Interchangeable map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
|
||||||
// plain subject: when it is the only instance, or when the definition says instances are
|
|
||||||
// interchangeable. A stateful module on two machines gets only its machines' subjects, so a call
|
|
||||||
// that names none reaches nothing rather than the wrong store.
|
|
||||||
func (p Placements) AnswersForTheModule(module string) bool {
|
|
||||||
return len(p.Nodes[module]) <= 1 || p.Interchangeable[module]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MembershipFor composes one assignment's membership from what it declared and where everything
|
|
||||||
// runs. The subjects are the ones PermissionsFor grants, derived here once more only until the
|
|
||||||
// grant itself is read from the membership — which is the next step, not this one.
|
|
||||||
func MembershipFor(node string, d Declared, where Placements) Membership {
|
|
||||||
own := "mesh.mod." + d.Module
|
|
||||||
m := Membership{
|
|
||||||
Node: node, Module: d.Module,
|
|
||||||
Emits: own + ".event.{event}",
|
|
||||||
Tools: own + ".tool.tools",
|
|
||||||
}
|
|
||||||
// This machine's address always; the module's when this instance answers for the module.
|
|
||||||
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}." + node})
|
|
||||||
if where.AnswersForTheModule(d.Module) {
|
|
||||||
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
|
|
||||||
}
|
|
||||||
for _, s := range d.Holds {
|
|
||||||
for _, verb := range s.Serves {
|
|
||||||
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(d.Invokes) > 0 {
|
|
||||||
m.Reaches = map[string][]string{}
|
|
||||||
for _, t := range d.Invokes {
|
|
||||||
if t == "*" || strings.HasPrefix(t, "seat:") {
|
|
||||||
continue // every tool, or a role's: addressed by name, not resolved per instance
|
|
||||||
}
|
|
||||||
module, tool, ok := strings.Cut(t, ".")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var reach []string
|
|
||||||
if where.AnswersForTheModule(module) {
|
|
||||||
reach = append(reach, "mesh.mod."+module+".tool."+tool)
|
|
||||||
}
|
|
||||||
nodes := append([]string{}, where.Nodes[module]...)
|
|
||||||
sort.Strings(nodes)
|
|
||||||
for _, n := range nodes {
|
|
||||||
reach = append(reach, "mesh.mod."+module+".tool."+tool+"."+n)
|
|
||||||
}
|
|
||||||
m.Reaches[t] = reach
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
|
|
||||||
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
|
||||||
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
|
|
||||||
for node, declared := range r.Assigned {
|
|
||||||
for _, d := range declared {
|
|
||||||
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, nodes := range p.Nodes {
|
|
||||||
sort.Strings(nodes)
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The mesh issues an assignment's subjects (novox/hq ADR 0160): a module alone on one machine
|
|
||||||
// answers for the module and for its machine; a stateful module on two machines answers only for
|
|
||||||
// each machine; one that says its instances are interchangeable answers for the module everywhere;
|
|
||||||
// a holder serves its seat's verbs; and what a module may reach is resolved the same way.
|
|
||||||
func TestAMembershipIsIssuedFromWhereEverythingRuns(t *testing.T) {
|
|
||||||
records := Records{Assigned: map[string][]Declared{
|
|
||||||
"anchor": {
|
|
||||||
{Module: "postgres", Serves: []string{"query"}, Holds: []Seat{{Name: "mesh-store", Scope: "mesh", Serves: []string{"databases", "query"}}}},
|
|
||||||
{Module: "catalog", Invokes: []string{"postgres.query", "search.find"}},
|
|
||||||
},
|
|
||||||
"home-server": {
|
|
||||||
{Module: "postgres"},
|
|
||||||
{Module: "search"},
|
|
||||||
{Module: "dashboard", Invokes: []string{"postgres.query"}},
|
|
||||||
},
|
|
||||||
"laptop": {{Module: "search"}},
|
|
||||||
}, Interchangeable: map[string]bool{"search": true}}
|
|
||||||
where := PlacementsOf(records, records.Interchangeable)
|
|
||||||
|
|
||||||
pg := MembershipFor("anchor", records.Assigned["anchor"][0], where)
|
|
||||||
if !reflect.DeepEqual(pg.Serves, []Served{{Subject: "mesh.mod.postgres.tool.{tool}.anchor"}}) {
|
|
||||||
t.Fatalf("a stateful module on two machines answers only for its machine: %+v", pg.Serves)
|
|
||||||
}
|
|
||||||
if len(pg.Seats) != 2 || pg.Seats[0].Subject != "mesh.seat.mesh-store.tool.databases" {
|
|
||||||
t.Fatalf("the holder serves the seat's verbs at the seat's subjects: %+v", pg.Seats)
|
|
||||||
}
|
|
||||||
if pg.Emits != "mesh.mod.postgres.event.{event}" || pg.Tools != "mesh.mod.postgres.tool.tools" {
|
|
||||||
t.Fatalf("events and the tools verb: %+v", pg)
|
|
||||||
}
|
|
||||||
|
|
||||||
search := MembershipFor("laptop", records.Assigned["laptop"][0], where)
|
|
||||||
if !reflect.DeepEqual(search.Serves, []Served{
|
|
||||||
{Subject: "mesh.mod.search.tool.{tool}.laptop"},
|
|
||||||
{Subject: "mesh.mod.search.tool.{tool}", Queue: "serve.search"},
|
|
||||||
}) {
|
|
||||||
t.Fatalf("an interchangeable module answers for the module in the queue too: %+v", search.Serves)
|
|
||||||
}
|
|
||||||
|
|
||||||
dashboard := MembershipFor("home-server", records.Assigned["home-server"][2], where)
|
|
||||||
if !reflect.DeepEqual(dashboard.Serves, []Served{
|
|
||||||
{Subject: "mesh.mod.dashboard.tool.{tool}.home-server"},
|
|
||||||
{Subject: "mesh.mod.dashboard.tool.{tool}", Queue: "serve.dashboard"},
|
|
||||||
}) {
|
|
||||||
t.Fatalf("a module alone on one machine answers for the module: %+v", dashboard.Serves)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(dashboard.Reaches["postgres.query"],
|
|
||||||
[]string{"mesh.mod.postgres.tool.query.anchor", "mesh.mod.postgres.tool.query.home-server"}) {
|
|
||||||
t.Fatalf("reaching a stateful module names each machine and no plain subject: %v", dashboard.Reaches)
|
|
||||||
}
|
|
||||||
catalog := MembershipFor("anchor", records.Assigned["anchor"][1], where)
|
|
||||||
if !reflect.DeepEqual(catalog.Reaches["search.find"],
|
|
||||||
[]string{"mesh.mod.search.tool.find", "mesh.mod.search.tool.find.home-server", "mesh.mod.search.tool.find.laptop"}) {
|
|
||||||
t.Fatalf("reaching an interchangeable module offers the plain subject first: %v", catalog.Reaches)
|
|
||||||
}
|
|
||||||
if MembershipSubject("anchor", "postgres") != "mesh.assignment.anchor.postgres" {
|
|
||||||
t.Fatal("the one subject a runtime derives for itself")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAccountMayReadItsOwnMembershipAndNoOthers(t *testing.T) {
|
|
||||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "anchor", Module: "postgres", PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, perms.Subscribe, "mesh.assignment.anchor.postgres")
|
|
||||||
has(t, perms.Publish, "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.postgres")
|
|
||||||
hasNot(t, perms.Subscribe, "mesh.assignment.>")
|
|
||||||
}
|
|
||||||
+19
-91
@@ -39,11 +39,7 @@ const (
|
|||||||
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
|
||||||
// emits (novox/hq ADR 0118, design 29 §5).
|
// emits (novox/hq ADR 0118, design 29 §5).
|
||||||
type Seat struct {
|
type Seat struct {
|
||||||
Name string
|
Name string
|
||||||
// Scope is where the seat has one holder. A node-scoped seat's tool carries the node in its
|
|
||||||
// subject, because one subject reaching six machines' holders is not an address
|
|
||||||
// (novox/hq ADR 0132, design 33 §4). Empty reads as mesh.
|
|
||||||
Scope string
|
|
||||||
Accepts []string
|
Accepts []string
|
||||||
Emits []string
|
Emits []string
|
||||||
Serves []string
|
Serves []string
|
||||||
@@ -74,14 +70,12 @@ type Principal struct {
|
|||||||
// a namespace no such module owns. Every service started and the graph stayed empty.
|
// a namespace no such module owns. Every service started and the graph stayed empty.
|
||||||
Watches []Seat
|
Watches []Seat
|
||||||
|
|
||||||
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
|
// Invokes are the tools a person may call, as `<module>.<tool>`; a single `*` is every tool,
|
||||||
// tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so
|
// for an administrator. Only meaningful for KindPerson.
|
||||||
// (novox/hq ADR 0152) — the console's does, and nothing else's.
|
|
||||||
//
|
//
|
||||||
// **A list, not a role.** A person is not a module and holds no seat: nothing is addressed
|
// **A list, not a role.** A person is not a module and holds no seat: nothing is addressed
|
||||||
// to them, nothing is delivered to them, and they have no durable consumer to acknowledge.
|
// to them, nothing is delivered to them, and they have no durable consumer to acknowledge.
|
||||||
// What they have is permission to ask. A module that invokes gains exactly the same
|
// What they have is permission to ask.
|
||||||
// permission and nothing beside it.
|
|
||||||
Invokes []string
|
Invokes []string
|
||||||
|
|
||||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||||
@@ -173,10 +167,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
switch p.Kind {
|
switch p.Kind {
|
||||||
case KindController:
|
case KindController:
|
||||||
// The controller owns the mesh's own traffic and the streams. It is the only writer of
|
// The controller owns the mesh's own traffic and the streams. It is the only writer of
|
||||||
// stream definitions (design 25 §3), so it alone reaches the JetStream API — and it alone
|
// stream definitions (design 25 §3), so it alone reaches the JetStream API.
|
||||||
// issues memberships (novox/hq ADR 0160), which it publishes into the assignments stream
|
pub = []string{"mesh.control.>", "mesh.node.>", "$JS.API.>"}
|
||||||
// after each push; refused by the server on 2026-10-01 until this line named them.
|
|
||||||
pub = []string{"mesh.control.>", "mesh.node.>", "mesh.assignment.>", "$JS.API.>"}
|
|
||||||
// **And where its consumers deliver.** A push consumer delivers on `_DELIVER.<its name>`,
|
// **And where its consumers deliver.** A push consumer delivers on `_DELIVER.<its name>`,
|
||||||
// and a client bound to it subscribes exactly that; the server refused it for every
|
// and a client bound to it subscribes exactly that; the server refused it for every
|
||||||
// principal the first time one bound a consumer (2026-09-28). Each kind below is granted
|
// principal the first time one bound a consumer (2026-09-28). Each kind below is granted
|
||||||
@@ -203,13 +195,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// the new bus was refused the publish (2026-09-28).
|
// the new bus was refused the publish (2026-09-28).
|
||||||
pub = append(pub, "mesh.mod.*.tool.>")
|
pub = append(pub, "mesh.mod.*.tool.>")
|
||||||
|
|
||||||
// **And the mesh's own verbs, as the seat it holds** (novox/hq ADR 0132, ADR 0154):
|
|
||||||
// `status`, `push`, `assign` are the mesh-controller seat's tools, served by its holder. The
|
|
||||||
// whole verb namespace of its own seat rather than a list: the list is the seat's protocol,
|
|
||||||
// which this package mirrors rather than reads, and a verb the seat does not declare is a
|
|
||||||
// subject nothing publishes.
|
|
||||||
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
|
|
||||||
|
|
||||||
// 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
|
||||||
// controller a subscriber to every event in the mesh, and its permission list would stop
|
// controller a subscriber to every event in the mesh, and its permission list would stop
|
||||||
@@ -239,11 +224,18 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
case KindPerson:
|
case KindPerson:
|
||||||
// Tools, and nothing else. Every subject a person may publish is a tool call; a person
|
// Tools, and nothing else. Every subject a person may publish is a tool call; a person
|
||||||
// who could publish an event would be able to claim a module said something.
|
// who could publish an event would be able to claim a module said something.
|
||||||
invoked, err := invokedSubjects(p.Invokes)
|
for _, t := range p.Invokes {
|
||||||
if err != nil {
|
if t == "*" {
|
||||||
return Permissions{}, err
|
pub = append(pub, "mesh.mod.*.tool.>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
module, tool, ok := strings.Cut(t, ".")
|
||||||
|
if !ok {
|
||||||
|
return Permissions{}, fmt.Errorf(
|
||||||
|
"%q does not name a tool: a person invokes <module>.<tool>, or * for every one", t)
|
||||||
|
}
|
||||||
|
pub = append(pub, "mesh.mod."+module+".tool."+tool)
|
||||||
}
|
}
|
||||||
pub = append(pub, invoked...)
|
|
||||||
|
|
||||||
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
|
||||||
@@ -300,20 +292,6 @@ 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.>")
|
||||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
|
|
||||||
// directly from the stream and followed live. Nothing else's.
|
|
||||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
|
||||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+"."+MembershipSubject(p.Node, p.Module))
|
|
||||||
|
|
||||||
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
|
|
||||||
// grant a person gets and derived the same way, so "what may this module ask" is
|
|
||||||
// answered by the one list that answers it for everybody. Publish only: an answer
|
|
||||||
// arrives on its own inbox, which every principal has below.
|
|
||||||
invoked, err := invokedSubjects(p.Invokes)
|
|
||||||
if err != nil {
|
|
||||||
return Permissions{}, err
|
|
||||||
}
|
|
||||||
pub = append(pub, invoked...)
|
|
||||||
|
|
||||||
// 2. What it consumes, by the emitter's own subject — an event is addressed to its
|
// 2. What it consumes, by the emitter's own subject — an event is addressed to its
|
||||||
// emitter, because the emitter's identity is the meaning (ADR 0118).
|
// emitter, because the emitter's identity is the meaning (ADR 0118).
|
||||||
@@ -360,7 +338,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, seatSubject(s, "event", e))
|
pub = append(pub, seatSubject(s, "event", e))
|
||||||
}
|
}
|
||||||
for _, t := range s.Serves {
|
for _, t := range s.Serves {
|
||||||
sub = append(sub, seatToolSubject(s, t, p.Node))
|
sub = append(sub, seatSubject(s, "tool", t))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +350,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
pub = append(pub, seatSubject(s, "accept", a))
|
pub = append(pub, seatSubject(s, "accept", a))
|
||||||
}
|
}
|
||||||
for _, t := range s.Serves {
|
for _, t := range s.Serves {
|
||||||
pub = append(pub, seatToolSubject(s, t, "*"))
|
pub = append(pub, seatSubject(s, "tool", t))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,18 +400,6 @@ func seatSubject(s Seat, kind, verb string) string {
|
|||||||
return "mesh.seat." + s.Name + "." + kind + "." + verb
|
return "mesh.seat." + s.Name + "." + kind + "." + verb
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatToolSubject is where a role's tool is asked. Mesh-wide for a mesh-scoped seat; a node-scoped
|
|
||||||
// seat carries the node it is asked of, because a flat subject would reach every machine's holder
|
|
||||||
// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder
|
|
||||||
// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject.
|
|
||||||
func seatToolSubject(s Seat, verb, node string) string {
|
|
||||||
base := seatSubject(s, "tool", verb)
|
|
||||||
if s.Scope == "node" && node != "" {
|
|
||||||
return base + "." + node
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
// consumerStream and consumerDurable are the two halves of a consumer's identity, and they are
|
// consumerStream and consumerDurable are the two halves of a consumer's identity, and they are
|
||||||
// two functions because conflating them was a real bug.
|
// two functions because conflating them was a real bug.
|
||||||
//
|
//
|
||||||
@@ -635,41 +601,3 @@ func quoted(values []string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(out, ", ")
|
return strings.Join(out, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// invokedSubjects is the publish side of a grant to call tools: one subject per `<module>.<tool>`,
|
|
||||||
// or the whole tool namespace for `*`. A person's authority and a module's `invokes` are both this
|
|
||||||
// (novox/hq ADR 0152), so a malformed entry is refused in one place, before it could be widened into
|
|
||||||
// something that happens to parse.
|
|
||||||
func invokedSubjects(invokes []string) ([]string, error) {
|
|
||||||
var out []string
|
|
||||||
for _, t := range invokes {
|
|
||||||
if t == "*" {
|
|
||||||
// Every module's tools and every role's (novox/hq ADR 0132): a role's verb is a tool
|
|
||||||
// like any other, addressed to the seat instead of a module.
|
|
||||||
out = append(out, "mesh.mod.*.tool.>", "mesh.seat.*.tool.>")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if rest, isSeat := strings.CutPrefix(t, "seat:"); isSeat {
|
|
||||||
// A role's tool, `seat:<seat>.<verb>`. Both address shapes, because the grant is
|
|
||||||
// written without knowing the seat's scope: a mesh seat's verb is flat and a node
|
|
||||||
// seat's carries the machine (design 33 §4).
|
|
||||||
seat, verb, ok := strings.Cut(rest, ".")
|
|
||||||
if !ok || seat == "" || verb == "" {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%q does not name a role's tool: one invokes seat:<seat>.<verb>", t)
|
|
||||||
}
|
|
||||||
out = append(out, "mesh.seat."+seat+".tool."+verb, "mesh.seat."+seat+".tool."+verb+".*")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
module, tool, ok := strings.Cut(t, ".")
|
|
||||||
if !ok || module == "" || tool == "" {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%q does not name a tool: one invokes <module>.<tool>, seat:<seat>.<verb>, or * for every one", t)
|
|
||||||
}
|
|
||||||
// Both ways a module's tool is addressed (novox/hq ADR 0159): to whichever instance
|
|
||||||
// answers, and to the instance on one machine, which is the same subject with the machine
|
|
||||||
// as its last token.
|
|
||||||
out = append(out, "mesh.mod."+module+".tool."+tool, "mesh.mod."+module+".tool."+tool+".*")
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,18 +71,6 @@ func TestHoldingASeatIsTheMirrorOfUsingIt(t *testing.T) {
|
|||||||
hasNot(t, perms.Publish, "mesh.seat.telegram-sender.accept.send")
|
hasNot(t, perms.Publish, "mesh.seat.telegram-sender.accept.send")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A build machine may say everything about a build as it happens (novox/hq ADR 0157): that it
|
|
||||||
// started, and every line under the build's own id — the seat's `log.*` becomes a publish over
|
|
||||||
// one token, so a reader follows one build by subject and the holder can name no other subject.
|
|
||||||
func TestTheBuildMachineMaySayWhatItDoesUnderTheBuildsId(t *testing.T) {
|
|
||||||
seat := Seat{Name: "mesh-build-machine", Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}}
|
|
||||||
perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "anchor", Module: "builder",
|
|
||||||
Holds: []Seat{seat}, PasswordHash: "x"})
|
|
||||||
has(t, perms.Publish, "mesh.seat.mesh-build-machine.event.started")
|
|
||||||
has(t, perms.Publish, "mesh.seat.mesh-build-machine.event.log.*")
|
|
||||||
hasNot(t, perms.Publish, "mesh.seat.mesh-build-machine.event.>")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without an ack permission a durable consumer never really consumes: every message it receives is
|
// Without an ack permission a durable consumer never really consumes: every message it receives is
|
||||||
// redelivered forever, refused by the permission list it already has (design 25 §4).
|
// redelivered forever, refused by the permission list it already has (design 25 §4).
|
||||||
func TestAModuleMayAckItsOwnDeliveriesAndNoOthers(t *testing.T) {
|
func TestAModuleMayAckItsOwnDeliveriesAndNoOthers(t *testing.T) {
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// A node-scoped seat's tool carries the node (novox/hq ADR 0132, design 33 §4): two nodes holding one
|
|
||||||
// node-scoped seat derive two addresses, and a user of the seat may publish any node's.
|
|
||||||
func TestTwoNodesHoldingOneNodeSeatDeriveTwoToolAddresses(t *testing.T) {
|
|
||||||
seat := Seat{Name: "node-dns-resolver", Scope: "node", Serves: []string{"lookup"}}
|
|
||||||
one, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "dnsmasq", Holds: []Seat{seat}, PasswordHash: "x"})
|
|
||||||
two, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "dnsmasq", Holds: []Seat{seat}, PasswordHash: "x"})
|
|
||||||
has(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.one")
|
|
||||||
has(t, two.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.two")
|
|
||||||
hasNot(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup")
|
|
||||||
hasNot(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.two")
|
|
||||||
|
|
||||||
user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "three", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"})
|
|
||||||
has(t, user.Publish, "mesh.seat.node-dns-resolver.tool.lookup.*")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A mesh-scoped seat's tool stays flat: nothing about it changes.
|
|
||||||
func TestAMeshSeatsToolIsAddressedToTheSeatAlone(t *testing.T) {
|
|
||||||
seat := Seat{Name: "git", Scope: "mesh", Serves: []string{"list_repos"}}
|
|
||||||
holder, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "gitea", Holds: []Seat{seat}, PasswordHash: "x"})
|
|
||||||
has(t, holder.Subscribe, "mesh.seat.git.tool.list_repos")
|
|
||||||
user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"})
|
|
||||||
has(t, user.Publish, "mesh.seat.git.tool.list_repos")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The controller serves its own seat's verbs and may answer them (novox/hq ADR 0154).
|
|
||||||
func TestTheControllerServesItsSeatsToolsAndMayAnswer(t *testing.T) {
|
|
||||||
perms, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, perms.Subscribe, "mesh.seat.mesh-controller.tool.>")
|
|
||||||
if !perms.AllowResponses {
|
|
||||||
t.Fatal("the controller serves tools and may not answer one")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A grant to every tool reaches a role's tools too, and a role's tool is granted by name.
|
|
||||||
func TestAGrantReachesARolesTools(t *testing.T) {
|
|
||||||
all, _ := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console", Invokes: []string{"*"}, PasswordHash: "x"})
|
|
||||||
has(t, all.Publish, "mesh.seat.*.tool.>")
|
|
||||||
|
|
||||||
one, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"seat:mesh-controller.status"}, PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, one.Publish, "mesh.seat.mesh-controller.tool.status")
|
|
||||||
hasNot(t, one.Publish, "mesh.seat.mesh-controller.tool.push")
|
|
||||||
hasNot(t, one.Publish, "mesh.mod.*.tool.>")
|
|
||||||
|
|
||||||
if _, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"seat:mesh-controller"}, PasswordHash: "x"}); err == nil {
|
|
||||||
t.Fatal("a role grant naming no verb was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -47,14 +47,8 @@ type Stream struct {
|
|||||||
// Why is carried into the assertion so an operator reading the server's own state finds the
|
// Why is carried into the assertion so an operator reading the server's own state finds the
|
||||||
// reason there, rather than only in a repository they may not have.
|
// reason there, rather than only in a repository they may not have.
|
||||||
Why string
|
Why string
|
||||||
// Direct lets a client read a subject's last message without a consumer, which is how a
|
|
||||||
// runtime reads its own membership with no JetStream API beyond one request (ADR 0160).
|
|
||||||
Direct bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssignmentsStream holds every assignment's membership, the newest per subject.
|
|
||||||
const AssignmentsStream = "ASSIGNMENTS"
|
|
||||||
|
|
||||||
// MeshStreams is the foundation set, in the order a person reads it.
|
// MeshStreams is the foundation set, in the order a person reads it.
|
||||||
//
|
//
|
||||||
// **CONTROL names its subjects rather than taking `mesh.control.>`**, because heartbeats live
|
// **CONTROL names its subjects rather than taking `mesh.control.>`**, because heartbeats live
|
||||||
@@ -85,15 +79,7 @@ func MeshStreams() []Stream {
|
|||||||
"n-1 by construction (issue 107)",
|
"n-1 by construction (issue 107)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: AssignmentsStream,
|
Name: "EVENTS",
|
||||||
Subjects: []string{"mesh.assignment.*.*"},
|
|
||||||
Retention: RetentionLastPerSubject,
|
|
||||||
Direct: true,
|
|
||||||
Why: "one membership per assignment, always the newest: what the mesh issued this module " +
|
|
||||||
"on this machine to serve and to reach (ADR 0160); read directly by the runtime it is for",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: EventsStream,
|
|
||||||
// A seat's own events ride here too: they are 1:many like any event, and the
|
// A seat's own events ride here too: they are 1:many like any event, and the
|
||||||
// `event` token keeps them clear of both the seat's work queue (`accept`) and its
|
// `event` token keeps them clear of both the seat's work queue (`accept`) and its
|
||||||
// tools (`tool`), which must not be persisted.
|
// tools (`tool`), which must not be persisted.
|
||||||
@@ -234,9 +220,6 @@ func seatEventSubject(seat, verb string) string {
|
|||||||
return "mesh.seat." + seat + ".event." + verb
|
return "mesh.seat." + seat + ".event." + verb
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventsStream holds every module's and every role's events, a build's log among them.
|
|
||||||
const EventsStream = "EVENTS"
|
|
||||||
|
|
||||||
// MeshConsumers is what the controller consumes, in the order a person reads it.
|
// MeshConsumers is what the controller consumes, in the order a person reads it.
|
||||||
//
|
//
|
||||||
// **Unlimited redelivery on CONTROL, deliberately.** The store window's bound is the controller's,
|
// **Unlimited redelivery on CONTROL, deliberately.** The store window's bound is the controller's,
|
||||||
@@ -261,13 +244,8 @@ func MeshConsumers() []Consumer {
|
|||||||
Push: true,
|
Push: true,
|
||||||
AckWaitSeconds: 30,
|
AckWaitSeconds: 30,
|
||||||
MaxDeliver: 5,
|
MaxDeliver: 5,
|
||||||
// One at a time (novox/hq issue 175): acting on a merge builds for minutes, and an
|
Why: "the two events the mesh's own controller reacts to; after max-deliver it " +
|
||||||
// announcement handed over behind it must wait on the server, not time out on the
|
"dead-letters, because an announcement it cannot act on will not become actionable",
|
||||||
// client and come back to be acted on again.
|
|
||||||
MaxAckPending: 1,
|
|
||||||
Why: "the two events the mesh's own controller reacts to, one at a time; after " +
|
|
||||||
"max-deliver it dead-letters, because an announcement it cannot act on will not " +
|
|
||||||
"become actionable",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,10 +123,9 @@ func subjectMatches(filter, subject string) bool {
|
|||||||
// Each relationship's retention is the thing that makes it what it is (design 29 §4).
|
// Each relationship's retention is the thing that makes it what it is (design 29 §4).
|
||||||
func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
|
func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
|
||||||
want := map[string]Retention{
|
want := map[string]Retention{
|
||||||
"CONTROL": RetentionWorkQueue,
|
"CONTROL": RetentionWorkQueue,
|
||||||
"NODES": RetentionLastPerSubject,
|
"NODES": RetentionLastPerSubject,
|
||||||
"EVENTS": RetentionLimits,
|
"EVENTS": RetentionLimits,
|
||||||
"ASSIGNMENTS": RetentionLastPerSubject,
|
|
||||||
}
|
}
|
||||||
got := map[string]Retention{}
|
got := map[string]Retention{}
|
||||||
for _, s := range MeshStreams() {
|
for _, s := range MeshStreams() {
|
||||||
@@ -261,14 +260,3 @@ func TestNoTwoConsumersDeliverOntoTheSameSubject(t *testing.T) {
|
|||||||
seen[subject] = c.Name + " on " + c.Stream
|
seen[subject] = c.Name + " on " + c.Stream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The controller's events consumer is handed one announcement at a time (novox/hq issue 175): a
|
|
||||||
// merge's handler builds for minutes, and what is queued behind it must wait on the server rather
|
|
||||||
// than time out on the client and be acted on twice.
|
|
||||||
func TestTheControllerTakesOneAnnouncementAtATime(t *testing.T) {
|
|
||||||
for _, c := range MeshConsumers() {
|
|
||||||
if c.Stream == "EVENTS" && c.Name == ControllerName && c.MaxAckPending != 1 {
|
|
||||||
t.Fatalf("the events consumer may have %d outstanding; one announcement at a time", c.MaxAckPending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+8
-8
@@ -24,8 +24,8 @@ accounts {
|
|||||||
jetstream: enabled
|
jetstream: enabled
|
||||||
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"] }
|
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "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"] }
|
||||||
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.>"] }
|
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"] }
|
||||||
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: {
|
||||||
@@ -37,18 +37,18 @@ accounts {
|
|||||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
||||||
} }
|
} }
|
||||||
{ 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.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", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||||
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.SEAT_TELEGRAM_SENDER_worker.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.SEAT_TELEGRAM_SENDER_worker.>", "_INBOX.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"] }
|
||||||
subscribe: { allow: ["_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
subscribe: { allow: ["_INBOX.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", "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: ["_INBOX.two.shop.>", "mesh.mod.shop.tool.>"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ type Declared struct {
|
|||||||
Uses []Seat
|
Uses []Seat
|
||||||
// Watches are the seats whose events it consumes.
|
// Watches are the seats whose events it consumes.
|
||||||
Watches []Seat
|
Watches []Seat
|
||||||
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
|
||||||
Invokes []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||||
@@ -47,9 +45,6 @@ type Records struct {
|
|||||||
Enrolling []string
|
Enrolling []string
|
||||||
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
||||||
People map[string][]string
|
People map[string][]string
|
||||||
// Interchangeable is each module whose definition says its instances are the same anywhere
|
|
||||||
// (ADR 0160), which decides whether the module's plain subject is issued to every instance.
|
|
||||||
Interchangeable map[string]bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users is every user the composed file should contain, in the order it will be written.
|
// Users is every user the composed file should contain, in the order it will be written.
|
||||||
@@ -66,7 +61,7 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
out = append(out, Principal{
|
out = append(out, Principal{
|
||||||
Kind: KindModule, Node: node, Module: d.Module,
|
Kind: KindModule, Node: node, Module: d.Module,
|
||||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The name a machine runs a binary by is not always the name of the package that built it. The host's
|
|
||||||
// command is cmd/mesh-host and every machine runs it as nox-mesh-host — the path it is installed at,
|
|
||||||
// the name in its unit, and the name its launcher looks for inside a delivered version.
|
|
||||||
//
|
|
||||||
// A bundle carrying the package's name was delivered to a machine correctly, reported "created … 1
|
|
||||||
// file(s)", and was invisible to the launcher (novox/hq 04-ISSUES/142). Found by reading the delivered
|
|
||||||
// directory rather than by trusting the line that said it worked.
|
|
||||||
|
|
||||||
func TestACompiledArtifactNamesTheBinaryAMachineWillRun(t *testing.T) {
|
|
||||||
got := binaryName(catalogue.Artifact{
|
|
||||||
Name: "host-arch", From: "cmd/mesh-host", Binary: "nox-mesh-host",
|
|
||||||
})
|
|
||||||
if got != "nox-mesh-host" {
|
|
||||||
t.Fatalf("the binary is named %q, and the launcher looks for nox-mesh-host", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSayingNothingKeepsWhatTheCompilerWouldHaveChosen(t *testing.T) {
|
|
||||||
// go build names its output after the package, so an artifact that says nothing gets the same
|
|
||||||
// thing it got before this existed.
|
|
||||||
if got := binaryName(catalogue.Artifact{Name: "host-arch", From: "cmd/mesh-host"}); got != "mesh-host" {
|
|
||||||
t.Fatalf("an artifact naming no binary produced %q", got)
|
|
||||||
}
|
|
||||||
if got := binaryName(catalogue.Artifact{Name: "host-arch", From: "./cmd/agent/"}); got != "agent" {
|
|
||||||
t.Fatalf("a from with slashes produced %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestABundleBuiltFromTheModuleRootFallsBackToItsArtifactName(t *testing.T) {
|
|
||||||
// A single-command repository names no package, and `go build -o <dir>` would then write a file
|
|
||||||
// named after the module directory — which is not something the manifest states. The artifact's
|
|
||||||
// own name is what the manifest does state.
|
|
||||||
if got := binaryName(catalogue.Artifact{Name: "tool"}); got != "tool" {
|
|
||||||
t.Fatalf("a bundle built from the root produced %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+12
-98
@@ -90,13 +90,7 @@ type GitCredential struct {
|
|||||||
// records — reachable, unreferenced, and indistinguishable from something in use.
|
// records — reachable, unreferenced, and indistinguishable from something in use.
|
||||||
func Build(ctx context.Context, run Runner, publish Publisher,
|
func Build(ctx context.Context, run Runner, publish Publisher,
|
||||||
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
|
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
|
||||||
forge GitCredential, log Log, seats ...map[string]string) (Result, error) {
|
forge GitCredential, log Log) (Result, error) {
|
||||||
// The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers
|
|
||||||
// that hand none — tests of everything but contexts — read as they did.
|
|
||||||
var seatBases map[string]string
|
|
||||||
if len(seats) > 0 {
|
|
||||||
seatBases = seats[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
say := logging(log)
|
say := logging(log)
|
||||||
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
|
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
|
||||||
@@ -215,7 +209,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
||||||
for _, a := range artifacts {
|
for _, a := range artifacts {
|
||||||
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
||||||
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, say)
|
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("artifact", "%s FAILED: %v", a.Name, err)
|
say("artifact", "%s FAILED: %v", a.Name, err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -252,18 +246,14 @@ func logging(log Log) func(step, format string, args ...any) {
|
|||||||
// module's own repository — a fresh tree, the same way the module's own is, keyed by artifact
|
// module's own repository — a fresh tree, the same way the module's own is, keyed by artifact
|
||||||
// name so two artifacts of one module naming different contexts do not collide.
|
// name so two artifacts of one module naming different contexts do not collide.
|
||||||
func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string,
|
func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string,
|
||||||
from catalogue.ArtifactContext, seats map[string]string, say func(step, format string, args ...any)) (string, error) {
|
from catalogue.ArtifactContext, say func(step, format string, args ...any)) (string, error) {
|
||||||
url, err := contextURL(from, seats)
|
say("context", "cloning %s at %s for %s", from.Repository, refOrHead(from.Ref), artifact)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
say("context", "cloning %s at %s for %s", url, refOrHead(from.Ref), artifact)
|
|
||||||
dir := filepath.Join(workspace, "context-"+artifact)
|
dir := filepath.Join(workspace, "context-"+artifact)
|
||||||
if err := os.RemoveAll(dir); err != nil {
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", url, dir)...); err != nil {
|
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", from.Repository, dir)...); err != nil {
|
||||||
return "", fmt.Errorf("cannot clone %s: %w", url, err)
|
return "", fmt.Errorf("cannot clone %s: %w", from.Repository, err)
|
||||||
}
|
}
|
||||||
if from.Ref != "" {
|
if from.Ref != "" {
|
||||||
if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil {
|
if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil {
|
||||||
@@ -274,23 +264,6 @@ func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentia
|
|||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// contextURL is what a context is cloned from: its URL, or — for a context on a seat — the seat's
|
|
||||||
// clone base the mesh sent with the request joined to the repository's path (novox/hq ADR 0155).
|
|
||||||
// Refused, never guessed, when the mesh sent no base for that seat: a builder that guessed a forge
|
|
||||||
// would be the literal this removes, one layer down.
|
|
||||||
func contextURL(from catalogue.ArtifactContext, seats map[string]string) (string, error) {
|
|
||||||
if from.Seat == "" {
|
|
||||||
return from.Repository, nil
|
|
||||||
}
|
|
||||||
base, told := seats[from.Seat]
|
|
||||||
if !told || base == "" {
|
|
||||||
return "", fmt.Errorf("the context is %s on the %s seat, and this build was told no clone "+
|
|
||||||
"base for that seat — nothing holds it in this mesh, or the control plane predates the word",
|
|
||||||
from.Repository, from.Seat)
|
|
||||||
}
|
|
||||||
return strings.TrimRight(base, "/") + "/" + strings.TrimSuffix(strings.Trim(from.Repository, "/"), ".git") + ".git", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cloneWith is a git invocation that may offer a stored credential.
|
// cloneWith is a git invocation that may offer a stored credential.
|
||||||
//
|
//
|
||||||
// The first `-c credential.helper=` clears every helper the environment might carry, so exactly
|
// The first `-c credential.helper=` clears every helper the environment might carry, so exactly
|
||||||
@@ -443,8 +416,7 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
|
|||||||
|
|
||||||
func one(ctx context.Context, run Runner, publish Publisher,
|
func one(ctx context.Context, run Runner, publish Publisher,
|
||||||
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
|
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
|
||||||
held map[string]string, npmrc string, seats map[string]string,
|
held map[string]string, npmrc string, say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||||
say func(step, format string, args ...any)) (catalogue.Built, error) {
|
|
||||||
|
|
||||||
switch a.Kind {
|
switch a.Kind {
|
||||||
case catalogue.ArtifactUpstream:
|
case catalogue.ArtifactUpstream:
|
||||||
@@ -521,7 +493,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
recipePath := a.From
|
recipePath := a.From
|
||||||
buildDir := tree
|
buildDir := tree
|
||||||
if a.Context != nil {
|
if a.Context != nil {
|
||||||
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, seats, say)
|
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err)
|
return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err)
|
||||||
}
|
}
|
||||||
@@ -719,21 +691,6 @@ func short(commit string) string {
|
|||||||
return commit
|
return commit
|
||||||
}
|
}
|
||||||
|
|
||||||
// Said is where the lines Command speaks go, beside the build's own Log: what runs, how long it
|
|
||||||
// took, and that it failed. Nil prints them to stderr, as a build machine with nobody listening
|
|
||||||
// should. The machine sets it per build so every line reaches the bus too (novox/hq ADR 0157) —
|
|
||||||
// the step is "run", and the message is the line as it has always been printed.
|
|
||||||
var Said Log
|
|
||||||
|
|
||||||
func tell(step, format string, args ...any) {
|
|
||||||
message := fmt.Sprintf(format, args...)
|
|
||||||
if Said == nil {
|
|
||||||
fmt.Fprintf(os.Stderr, " %s\n", message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Said(step, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command is a Runner that actually runs things.
|
// Command is a Runner that actually runs things.
|
||||||
func Command(ctx context.Context, dir, name string, args ...string) (string, error) {
|
func Command(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||||
// **Every command is echoed before it runs**, with where. On a build that hangs, the last line
|
// **Every command is echoed before it runs**, with where. On a build that hangs, the last line
|
||||||
@@ -741,23 +698,16 @@ func Command(ctx context.Context, dir, name string, args ...string) (string, err
|
|||||||
// nothing" and "git clone is waiting on a network that will not answer". Silent on success is
|
// nothing" and "git clone is waiting on a network that will not answer". Silent on success is
|
||||||
// what made an empty workspace unreadable.
|
// what made an empty workspace unreadable.
|
||||||
started := timeNow()
|
started := timeNow()
|
||||||
tell("run", "$ (%s) %s %s", short(filepath.Base(dir)), name, strings.Join(args, " "))
|
fmt.Fprintf(os.Stderr, " $ (%s) %s %s\n", short(filepath.Base(dir)), name, strings.Join(args, " "))
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tell("run", "! %s %s failed after %s", name, args[0], since(started))
|
fmt.Fprintf(os.Stderr, " ! %s %s failed after %s\n", name, args[0], since(started))
|
||||||
// The command's own output is part of what a reader needs — the compiler's error, the
|
|
||||||
// clone's refusal — and a line per output line keeps it readable on the bus.
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
||||||
if line != "" {
|
|
||||||
tell("output", "%s", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return string(out), fmt.Errorf("%s %s: %w\n%s",
|
return string(out), fmt.Errorf("%s %s: %w\n%s",
|
||||||
name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
tell("run", "✓ %s %s (%s)", name, firstArg(args), since(started))
|
fmt.Fprintf(os.Stderr, " ✓ %s %s (%s)\n", name, firstArg(args), since(started))
|
||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,32 +877,8 @@ func compile(ctx context.Context, run Runner, tree string, chain Toolchain,
|
|||||||
base,
|
base,
|
||||||
}
|
}
|
||||||
invocation = append(invocation, chain.Compile...)
|
invocation = append(invocation, chain.Compile...)
|
||||||
// **One `-ldflags`, composed here.** A repeated flag is not a merged one: the Go command takes
|
|
||||||
// the last and drops the first, so passing the toolchain's flags and then the system stamp as a
|
|
||||||
// second `-ldflags` produced a binary that knew its system and had lost `-s -w` — half again the
|
|
||||||
// size, with its debug info (novox/hq 04-ISSUES/161).
|
|
||||||
//
|
|
||||||
// What it was built for is the one thing taken from the artifact, and ADR 0142 says why: the
|
|
||||||
// target is a property of the artifact rather than of the recipe. A host with no system refuses
|
|
||||||
// every declaration before it applies anything.
|
|
||||||
linker := append([]string(nil), chain.LinkerFlags...)
|
|
||||||
if chain.SystemStamp != "" && strings.TrimSpace(a.System) != "" {
|
|
||||||
linker = append(linker, "-X", chain.SystemStamp+"="+strings.TrimSpace(a.System))
|
|
||||||
}
|
|
||||||
if len(linker) > 0 {
|
|
||||||
invocation = append(invocation, "-ldflags", strings.Join(linker, " "))
|
|
||||||
}
|
|
||||||
if chain.OutputFlag != "" {
|
if chain.OutputFlag != "" {
|
||||||
// A compiler pointed at a package is told the file to write, not the directory: the name a
|
invocation = append(invocation, chain.OutputFlag, out)
|
||||||
// machine runs it by is not always the name of the package that built it. The host's command
|
|
||||||
// is `cmd/mesh-host` and every machine runs it as `nox-mesh-host` — so a bundle carrying the
|
|
||||||
// package's name lands correctly, reports success, and is invisible to whatever looks for it
|
|
||||||
// (novox/hq 04-ISSUES/142).
|
|
||||||
target := out
|
|
||||||
if chain.Unit == UnitPackage {
|
|
||||||
target = filepath.Join(out, binaryName(a))
|
|
||||||
}
|
|
||||||
invocation = append(invocation, chain.OutputFlag, target)
|
|
||||||
}
|
}
|
||||||
// What to compile. Named by the module rather than discovered, so adding a file does not
|
// What to compile. Named by the module rather than discovered, so adding a file does not
|
||||||
// silently change what a build produces.
|
// silently change what a build produces.
|
||||||
@@ -1141,15 +1067,3 @@ func readBy(manifest catalogue.Manifest) []catalogue.ArtifactContext {
|
|||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
|
||||||
func binaryName(a catalogue.Artifact) string {
|
|
||||||
if name := strings.TrimSpace(a.Binary); name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
if from := strings.Trim(a.From, "./"); from != "" {
|
|
||||||
return filepath.Base(from)
|
|
||||||
}
|
|
||||||
return a.Name
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A context on a seat is cloned from the base the mesh sent, joined to the repository's path; a
|
|
||||||
// context by URL is itself; a seat the mesh sent no base for is refused by name (novox/hq ADR 0155).
|
|
||||||
func TestAContextOnASeatIsClonedFromTheBaseTheMeshSent(t *testing.T) {
|
|
||||||
seats := map[string]string{"git": "http://forge.example.tld:3000"}
|
|
||||||
got, err := contextURL(catalogue.ArtifactContext{Seat: "git", Repository: "org/controller"}, seats)
|
|
||||||
if err != nil || got != "http://forge.example.tld:3000/org/controller.git" {
|
|
||||||
t.Fatalf("got %q, %v", got, err)
|
|
||||||
}
|
|
||||||
got, err = contextURL(catalogue.ArtifactContext{Repository: "https://elsewhere.example/x.git"}, seats)
|
|
||||||
if err != nil || got != "https://elsewhere.example/x.git" {
|
|
||||||
t.Fatalf("a URL context was changed: %q, %v", got, err)
|
|
||||||
}
|
|
||||||
_, err = contextURL(catalogue.ArtifactContext{Seat: "git", Repository: "org/controller"}, nil)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "git seat") {
|
|
||||||
t.Fatalf("a seat with no base was not refused by name: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A host built without knowing its system refuses every declaration before applying anything —
|
|
||||||
// safely, totally, and with nothing reporting it. The mesh built one, delivered it, started it, and
|
|
||||||
// it would have refused the first thing it was asked to do (novox/hq 04-ISSUES/161).
|
|
||||||
|
|
||||||
func TestTheGoToolchainStampsTheArtifactsSystem(t *testing.T) {
|
|
||||||
chain, err := ToolchainFor("go")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if chain.SystemStamp != "main.builtFor" {
|
|
||||||
t.Fatalf("the go toolchain fills %q", chain.SystemStamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestALanguageWithNoPinnedSystemStampsNothing(t *testing.T) {
|
|
||||||
// Interpreted output is not pinned to a system, and a manifest declaring one for it is already
|
|
||||||
// refused. Nothing to fill.
|
|
||||||
for _, language := range []string{"typescript", "python"} {
|
|
||||||
chain, err := ToolchainFor(language)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if chain.SystemStamp != "" {
|
|
||||||
t.Fatalf("%s fills %q, and its output is not pinned to a system",
|
|
||||||
language, chain.SystemStamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheStampIsTheOneThingTakenFromTheArtifact(t *testing.T) {
|
|
||||||
// The toolchain accepts nothing else from the module — anything it could override it would be
|
|
||||||
// writing a Dockerfile to override. The system is the stated exception, because a compiled
|
|
||||||
// binary is per system and the artifact is what declares one (ADR 0142).
|
|
||||||
chain, err := ToolchainFor("go")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
joined := strings.Join(chain.Compile, " ")
|
|
||||||
if strings.Contains(joined, "${") || strings.Contains(joined, "%s") {
|
|
||||||
t.Fatalf("the compile line takes something from the module: %q", joined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheLinkerIsToldOnceNotTwice(t *testing.T) {
|
|
||||||
// A repeated flag is not a merged one: the Go command takes the last -ldflags and drops the
|
|
||||||
// first. Passing the toolchain's flags and then the stamp separately produced a binary that knew
|
|
||||||
// its system and had lost -s -w — 12.2MB against 8.5MB, with its debug info (04-ISSUES/161).
|
|
||||||
chain, err := ToolchainFor("go")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, arg := range chain.Compile {
|
|
||||||
if arg == "-ldflags" {
|
|
||||||
t.Fatal("the compile line carries -ldflags, so composing one here makes two")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(chain.LinkerFlags) == 0 {
|
|
||||||
t.Fatal("the go toolchain passes no linker flags, so the binary keeps its debug info")
|
|
||||||
}
|
|
||||||
var stripped bool
|
|
||||||
for _, f := range chain.LinkerFlags {
|
|
||||||
if f == "-s" {
|
|
||||||
stripped = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !stripped {
|
|
||||||
t.Fatalf("the go toolchain does not strip: %v", chain.LinkerFlags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,26 +49,6 @@ type Toolchain struct {
|
|||||||
// is named as it will be FOUND, inside the unpacked bundle, so the source is the same path with
|
// is named as it will be FOUND, inside the unpacked bundle, so the source is the same path with
|
||||||
// the output directory taken off the front and this on the end.
|
// the output directory taken off the front and this on the end.
|
||||||
SourceExt string
|
SourceExt string
|
||||||
// LinkerFlags are passed to the linker as one flag, together with the system stamp below.
|
|
||||||
//
|
|
||||||
// **Separate from Compile because a repeated flag is not a merged one.** They were in the compile
|
|
||||||
// line, and appending the stamp as a second `-ldflags` meant the Go command took the last and
|
|
||||||
// dropped the first — so the binary gained its system and lost `-s -w`, growing by half and
|
|
||||||
// carrying its debug info. The mistake was believing a comment rather than reading the file it
|
|
||||||
// produced (novox/hq 04-ISSUES/161).
|
|
||||||
LinkerFlags []string
|
|
||||||
// SystemStamp is the variable this language's linker fills with the artifact's declared system,
|
|
||||||
// for a language whose binaries are pinned to one at link time (novox/hq ADR 0005).
|
|
||||||
//
|
|
||||||
// **The one thing a toolchain takes from the artifact, and 0142 says why**: the target is a
|
|
||||||
// property of the artifact rather than of the recipe, because a compiled binary is per system
|
|
||||||
// and a toolchain that accepted it from the module would be accepting a build instruction. This
|
|
||||||
// is the narrow exception, named here rather than inferred.
|
|
||||||
//
|
|
||||||
// Empty for a language that compiles to nothing pinned. A host built without it refuses every
|
|
||||||
// declaration before applying anything — safely, totally, and with nothing reporting it
|
|
||||||
// (novox/hq 04-ISSUES/161).
|
|
||||||
SystemStamp string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a toolchain is pointed at.
|
// What a toolchain is pointed at.
|
||||||
@@ -134,20 +114,14 @@ var toolchains = []Toolchain{
|
|||||||
// rather than from the linker: two builds of one commit produce the same bytes.
|
// rather than from the linker: two builds of one commit produce the same bytes.
|
||||||
Compile: []string{
|
Compile: []string{
|
||||||
"env", "CGO_ENABLED=0", "GOFLAGS=-trimpath",
|
"env", "CGO_ENABLED=0", "GOFLAGS=-trimpath",
|
||||||
"go", "build",
|
"go", "build", "-ldflags", "-s -w",
|
||||||
},
|
},
|
||||||
// Stripped of symbols and debug info: what a machine holds is a file it runs, not one it
|
OutputFlag: "-o",
|
||||||
// debugs, and the difference measured 12.2MB against 8.5MB.
|
|
||||||
LinkerFlags: []string{"-s", "-w"},
|
|
||||||
OutputFlag: "-o",
|
|
||||||
// Pointed at the package the artifact is built `from`, compiled whole. Go writes the binary
|
// Pointed at the package the artifact is built `from`, compiled whole. Go writes the binary
|
||||||
// into the output directory, named after the package — so the bundle a machine unpacks is a
|
// into the output directory, named after the package — so the bundle a machine unpacks is a
|
||||||
// directory holding one executable, which is what the delivery mechanism expects
|
// directory holding one executable, which is what the delivery mechanism expects
|
||||||
// (novox/hq ADR 0141).
|
// (novox/hq ADR 0141).
|
||||||
Unit: UnitPackage,
|
Unit: UnitPackage,
|
||||||
// The mesh's own Go components read the system they were built for from this variable, and
|
|
||||||
// refuse to touch a machine without one.
|
|
||||||
SystemStamp: "main.builtFor",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Language: "python",
|
Language: "python",
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// A claim written before the rename still holds (novox/hq ADR 0122, ADR 0156): with the store's
|
|
||||||
// aliases loaded, the former name resolves to the seat.
|
|
||||||
func TestTheArtifactStoresFormerNameResolvesToIt(t *testing.T) {
|
|
||||||
was := aliases
|
|
||||||
t.Cleanup(func() { aliases = was })
|
|
||||||
UseAliases(map[string]string{"the-artifact-store": "mesh-artifact-store"})
|
|
||||||
seat, known := SeatNamed("the-artifact-store")
|
|
||||||
if !known || seat.Name != "mesh-artifact-store" || seat.Delivers != "artifact-store" {
|
|
||||||
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
|
|
||||||
}
|
|
||||||
if _, known := SeatNamed("mesh-artifact-store"); !known {
|
|
||||||
t.Fatal("the seat is not in the set under its name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A second one, on any other machine, is refused — and the refusal names the seat.
|
// A second one, on any other machine, is refused — and the refusal names the seat.
|
||||||
elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh,
|
elsewhere := World{Held: []Held{{Claim: "the-artifact-store", Scope: ScopeMesh,
|
||||||
Node: "anchor", Module: "distribution"}}}
|
Node: "anchor", Module: "distribution"}}}
|
||||||
other := workstation()
|
other := workstation()
|
||||||
other.Name = "laptop"
|
other.Name = "laptop"
|
||||||
@@ -32,7 +32,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
|
|||||||
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
|
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
|
||||||
"second time and every consumer elsewhere would refuse to choose")
|
"second time and every consumer elsewhere would refuse to choose")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
|
if !strings.Contains(err.Error(), "the-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
|
||||||
t.Fatalf("refused without naming the seat: %v", err)
|
t.Fatalf("refused without naming the seat: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) {
|
|||||||
for _, pair := range []struct{ seat, delivers string }{
|
for _, pair := range []struct{ seat, delivers string }{
|
||||||
{"git", "git"},
|
{"git", "git"},
|
||||||
{"npm-package-registry", "npm-package-registry"},
|
{"npm-package-registry", "npm-package-registry"},
|
||||||
{"mesh-artifact-store", "artifact-store"},
|
{"the-artifact-store", "artifact-store"},
|
||||||
{"mesh-store", "postgres-database"},
|
{"mesh-store", "postgres-database"},
|
||||||
{"mesh-broker", "mesh-bus"},
|
{"mesh-broker", "mesh-bus"},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func reachable() Node {
|
|||||||
func onNetwork(nodes ...string) map[string][]Provider {
|
func onNetwork(nodes ...string) map[string][]Provider {
|
||||||
out := make([]Provider, 0, len(nodes))
|
out := make([]Provider, 0, len(nodes))
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
out = append(out, Provider{Node: n, At: n + ".internal", Module: "postgres"})
|
out = append(out, Provider{Node: n, At: n + ".internal"})
|
||||||
}
|
}
|
||||||
return map[string][]Provider{"postgres-database": out}
|
return map[string][]Provider{"postgres-database": out}
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ func TestSayingWhichOneSettlesIt(t *testing.T) {
|
|||||||
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
||||||
World{
|
World{
|
||||||
Offered: onNetwork("anchor", "archive"),
|
Offered: onNetwork("anchor", "archive"),
|
||||||
Pinned: map[string]Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
|
Pinned: map[string]string{"postgres-database": "archive"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -115,7 +115,7 @@ func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) {
|
|||||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
||||||
World{
|
World{
|
||||||
Offered: onNetwork("anchor", "archive"),
|
Offered: onNetwork("anchor", "archive"),
|
||||||
Pinned: map[string]Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}},
|
Pinned: map[string]string{"postgres-database": "somewhere-else"},
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a machine was silently given a different database from the one chosen")
|
t.Fatal("a machine was silently given a different database from the one chosen")
|
||||||
@@ -131,12 +131,12 @@ func TestOneProviderDoesNotOverruleAChoice(t *testing.T) {
|
|||||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
||||||
World{
|
World{
|
||||||
Offered: onNetwork("anchor"),
|
Offered: onNetwork("anchor"),
|
||||||
Pinned: map[string]Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
|
Pinned: map[string]string{"postgres-database": "archive"},
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("the only database was used although another was chosen")
|
t.Fatal("the only database was used although another was chosen")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "only anchor/postgres provides it") {
|
if !strings.Contains(err.Error(), "only anchor provides it") {
|
||||||
t.Fatalf("the refusal does not say what is available: %v", err)
|
t.Fatalf("the refusal does not say what is available: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package catalogue
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,24 +10,11 @@ import (
|
|||||||
//
|
//
|
||||||
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
|
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
|
||||||
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
|
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
|
||||||
// catalogueRoot is the catalogue these checks run over: MESH_CATALOGUE when set, else the checkout
|
|
||||||
// beside this one, the way the main layout has it. A check that only ran when somebody remembered a
|
|
||||||
// variable was a check nobody ran (novox/hq issue 134, 2026-09-30); it skips only when there is no
|
|
||||||
// catalogue to be found at all.
|
|
||||||
func catalogueRoot(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
if root := os.Getenv("MESH_CATALOGUE"); root != "" {
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
sibling := filepath.Join("..", "..", "..", "mesh-catalog")
|
|
||||||
if _, err := os.Stat(filepath.Join(sibling, "modules")); err != nil {
|
|
||||||
t.Skip("no catalogue beside this checkout and MESH_CATALOGUE unset")
|
|
||||||
}
|
|
||||||
return sibling
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEveryCatalogueManifestParses(t *testing.T) {
|
func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||||
root := catalogueRoot(t)
|
root := os.Getenv("MESH_CATALOGUE")
|
||||||
|
if root == "" {
|
||||||
|
t.Skip("set MESH_CATALOGUE to a catalogue checkout to run this")
|
||||||
|
}
|
||||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
if err != nil || len(found) == 0 {
|
if err != nil || len(found) == 0 {
|
||||||
t.Fatalf("no manifests under %s: %v", root, err)
|
t.Fatalf("no manifests under %s: %v", root, err)
|
||||||
@@ -59,30 +45,3 @@ func TestEveryCatalogueManifestParses(t *testing.T) {
|
|||||||
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
|
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no
|
|
||||||
// definition names a domain or a public address the mesh acts on, and every value that must for now
|
|
||||||
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
|
|
||||||
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
|
|
||||||
root := catalogueRoot(t)
|
|
||||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
||||||
if err != nil || len(found) == 0 {
|
|
||||||
t.Fatalf("no manifests under %s: %v", root, err)
|
|
||||||
}
|
|
||||||
var named []string
|
|
||||||
for _, p := range found {
|
|
||||||
raw, err := os.ReadFile(p)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s: %v", p, err)
|
|
||||||
}
|
|
||||||
m, err := ParseManifest(raw)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s: %v", p, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
named = append(named, InstallationProblems(m)...)
|
|
||||||
}
|
|
||||||
if len(named) > 0 {
|
|
||||||
t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Chosen is the provider somebody named for a provision: the module, and the node it runs on. Both,
|
|
||||||
// always (novox/hq #258) — a provision comes from a module, and the same module on two machines is
|
|
||||||
// two answers, so neither half alone says which. Module is empty only on a record made before this
|
|
||||||
// was asked, and such a record is honoured exactly as long as it is unambiguous.
|
|
||||||
type Chosen struct {
|
|
||||||
Node string
|
|
||||||
Module string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Chosen) String() string {
|
|
||||||
if c.Module == "" {
|
|
||||||
return c.Node
|
|
||||||
}
|
|
||||||
return c.Node + "/" + c.Module
|
|
||||||
}
|
|
||||||
|
|
||||||
// matches is whether this provider is the one chosen.
|
|
||||||
func (c Chosen) matches(p Provider) bool {
|
|
||||||
return p.Node == c.Node && (c.Module == "" || p.Module == c.Module)
|
|
||||||
}
|
|
||||||
|
|
||||||
// among is every offered provider the choice names — one, when the choice is whole.
|
|
||||||
func (c Chosen) among(where []Provider) []Provider {
|
|
||||||
var out []Provider
|
|
||||||
for _, p := range where {
|
|
||||||
if c.matches(p) {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// nameOf is how a refusal names a provider: the node and the module on it.
|
|
||||||
func nameOf(p Provider) string {
|
|
||||||
return Chosen{Node: p.Node, Module: p.Module}.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// providerNames is every provider named, sorted, for a refusal to list.
|
|
||||||
func providerNames(where []Provider) []string {
|
|
||||||
out := make([]string, 0, len(where))
|
|
||||||
for _, p := range where {
|
|
||||||
out = append(out, nameOf(p))
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// providersHere is which modules in this node's own set offer a provision, sorted.
|
|
||||||
func providersHere(catalogue map[string]Manifest, here func(string) bool, want string) []string {
|
|
||||||
var out []string
|
|
||||||
for name, m := range catalogue {
|
|
||||||
if !here(name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, o := range m.Offers() {
|
|
||||||
if o == want {
|
|
||||||
out = append(out, name)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing.
|
|
||||||
//
|
|
||||||
// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq
|
|
||||||
// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest
|
|
||||||
// alone answers that; the values are settled later, with the node's settings.
|
|
||||||
func servedByOne(m Manifest, want string) map[string]any {
|
|
||||||
if _, ok := m.Serves[want]; ok {
|
|
||||||
return ServedOn(m, want, nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it
|
|
||||||
// gives each consumer its own.
|
|
||||||
func sharedByOne(m Manifest, want string) string {
|
|
||||||
if own, shared := m.SharedCredentialOf(want); shared {
|
|
||||||
return own
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func oneOf(list []string, s string) bool {
|
|
||||||
for _, x := range list {
|
|
||||||
if x == s {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -197,27 +197,6 @@ func TestARouteCanBeSetPerMesh(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) {
|
|
||||||
// novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read
|
|
||||||
// it, arrived in every route it contributed. A setting overrides a key the contribution
|
|
||||||
// declares and adds none — the provider reads the contribution as a contract.
|
|
||||||
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{})
|
|
||||||
|
|
||||||
out, err := got.Declaration(Rendering{Settings: SettingsBy{
|
|
||||||
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}},
|
|
||||||
}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
given := received(t, out)
|
|
||||||
if given[0].Values["host"] != "dashboard" {
|
|
||||||
t.Fatalf("the setting did not override the route's host: %v", given[0].Values)
|
|
||||||
}
|
|
||||||
if _, leaked := given[0].Values["sitename"]; leaked {
|
|
||||||
t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
||||||
// It would create a file nobody ever writes to, on a machine where nothing asked for it.
|
// It would create a file nobody ever writes to, on a machine where nothing asked for it.
|
||||||
_, err := ParseManifest([]byte(`{"module":"traefik","version":"1",
|
_, err := ParseManifest([]byte(`{"module":"traefik","version":"1",
|
||||||
|
|||||||
@@ -241,21 +241,15 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
// Owner is kept beside the resources because a resource id cannot be split back into its module:
|
// Owner is kept beside the resources because a resource id cannot be split back into its module:
|
||||||
// a module's name may itself contain a dot. What the mesh adds of its own — an opening, the guard —
|
// a module's name may itself contain a dot. What the mesh adds of its own — an opening, the guard —
|
||||||
// has no owner.
|
// has no owner.
|
||||||
//
|
|
||||||
// Received is what each module on the machine is given for each requirement it receives — the same
|
|
||||||
// contributions its received file is written from, kept beside it so the mesh can also issue them
|
|
||||||
// on the bus in the module's membership (novox/hq ADR 0167). By module, then requirement.
|
|
||||||
type Composed struct {
|
type Composed struct {
|
||||||
Resources []map[string]any
|
Resources []map[string]any
|
||||||
Owner map[string]string
|
Owner map[string]string
|
||||||
Received map[string]map[string][]Contribution
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose is Declaration with the owner of every resource said.
|
// Compose is Declaration with the owner of every resource said.
|
||||||
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||||
owner := map[string]string{}
|
owner := map[string]string{}
|
||||||
received := map[string]map[string][]Contribution{}
|
resources, err := r.compose(with, owner)
|
||||||
resources, err := r.compose(with, owner, received)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Composed{}, err
|
return Composed{}, err
|
||||||
}
|
}
|
||||||
@@ -267,7 +261,7 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
|||||||
"sealed": with.BusMembership, "mode": "0600",
|
"sealed": with.BusMembership, "mode": "0600",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Composed{Resources: resources, Owner: owner, Received: received}, nil
|
return Composed{Resources: resources, Owner: owner}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||||
@@ -276,8 +270,7 @@ func BusMembershipID() string { return "bus-membership" }
|
|||||||
|
|
||||||
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||||
|
|
||||||
func (r Resolution) compose(with Rendering, owner map[string]string,
|
func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[string]any, error) {
|
||||||
received map[string]map[string][]Contribution) ([]map[string]any, error) {
|
|
||||||
// Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings,
|
// Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings,
|
||||||
// credentials and contributions land are resolved against this node's directories, so every
|
// credentials and contributions land are resolved against this node's directories, so every
|
||||||
// reader below — the binding files, the sealed secrets, the grant paths a contribution
|
// reader below — the binding files, the sealed secrets, the grant paths a contribution
|
||||||
@@ -472,7 +465,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
"%s needs a secret called %q and none was made for it", m.Module, name)
|
"%s needs a secret called %q and none was made for it", m.Module, name)
|
||||||
}
|
}
|
||||||
first = append(first, ownedBy(m.SecretsOwner, map[string]any{
|
first = append(first, ownedBy(m.SecretsOwner, map[string]any{
|
||||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
|
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name], "sealed": sealed,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
// Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before
|
// Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before
|
||||||
@@ -480,15 +473,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
// find each present — refusing clearly if the operator has not provided it — before it
|
// find each present — refusing clearly if the operator has not provided it — before it
|
||||||
// starts anything that depends on it. The mesh creates, chowns and reconciles none of it;
|
// starts anything that depends on it. The mesh creates, chowns and reconciles none of it;
|
||||||
// an `access` resource says only *this path must exist, and this module reaches it*.
|
// an `access` resource says only *this path must exist, and this module reaches it*.
|
||||||
// Where each is on THIS machine is the assignment's (novox/hq issue 153): placed by id
|
for _, a := range m.Accesses {
|
||||||
// where the operator said, the definition's default otherwise, refused where neither.
|
|
||||||
accesses, accessPaths, err := accessesFor(m, with.Settings[m.Module])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, a := range accesses {
|
|
||||||
first = append(first, map[string]any{
|
first = append(first, map[string]any{
|
||||||
"id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.Mode,
|
"id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.At(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, to := range m.SecretRequirements() {
|
for _, to := range m.SecretRequirements() {
|
||||||
@@ -603,12 +590,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
first = append(first, file)
|
first = append(first, file)
|
||||||
if received[m.Module] == nil {
|
|
||||||
received[m.Module] = map[string][]Contribution{}
|
|
||||||
}
|
|
||||||
// Empty rather than absent when nobody contributed, for the reason the file is
|
|
||||||
// written empty: "nothing asked" and "never told" want different responses.
|
|
||||||
received[m.Module][to] = append([]Contribution{}, given[to]...)
|
|
||||||
}
|
}
|
||||||
if m.Keeps != "" && with.Kept != nil {
|
if m.Keeps != "" && with.Kept != nil {
|
||||||
file, err := keptFile(m.Keeps, with.Kept)
|
file, err := keptFile(m.Keeps, with.Kept)
|
||||||
@@ -641,15 +622,17 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
// merging earlier would throw away the files it still needs.
|
// merging earlier would throw away the files it still needs.
|
||||||
resources = append(append([]map[string]any{}, first...), resources...)
|
resources = append(append([]map[string]any{}, first...), resources...)
|
||||||
|
|
||||||
// No container is given the mesh's names (novox/hq ADR 0148). It used to be: every
|
// Every container is given the mesh's names. Not a choice a module makes: a module that
|
||||||
// container got the whole roster as `--add-host` entries at creation, and a name that
|
// listed them would go stale the day a machine joins, and one that did not would be a
|
||||||
// moved afterwards was wrong inside it for as long as it ran (issues 109, 135) — and once
|
// module whose containers cannot reach anything by name.
|
||||||
// the roster was made part of a container's identity so that could be caught, one name
|
//
|
||||||
// moving anywhere replaced every container in the mesh (issue 151). A container resolves a
|
// A container that was given names of its own keeps them and gets the mesh's beside them:
|
||||||
// mesh name through its machine's resolver at the moment it asks, which the runtime is
|
// the mesh does not know what else a workload needs to reach, and taking something away
|
||||||
// told once per machine, as a file, by the resolver's own module. The names a module
|
// to add something is not what "also" means.
|
||||||
// declares for itself are its own and stay exactly as written: they are part of what the
|
if len(with.Names) > 0 {
|
||||||
// module is, and the mesh does not know what they mean.
|
resources = withMeshNames(resources, with.Names)
|
||||||
|
}
|
||||||
|
|
||||||
// What this module may name from inside one of its own files. Gathered once per module
|
// What this module may name from inside one of its own files. Gathered once per module
|
||||||
// rather than per file, because it is a fact about the module.
|
// rather than per file, because it is a fact about the module.
|
||||||
sealed, err := sealedFor(m, r.Needs, with)
|
sealed, err := sealedFor(m, r.Needs, with)
|
||||||
@@ -689,12 +672,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
}
|
}
|
||||||
// And where this node places the directories the module declared without a path
|
// And where this node places the directories the module declared without a path
|
||||||
// (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource.
|
// (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource.
|
||||||
// — and, on an adopted machine, where the assignment says they already are, with the
|
|
||||||
// owner the data already has (novox/hq issue 153). Malformed placements are refused here.
|
|
||||||
placed, err := Places(m, with.Settings[m.Module])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
dirs := dirsFor(m, with)
|
dirs := dirsFor(m, with)
|
||||||
// And the machine underneath, which no binding of its own can tell it.
|
// And the machine underneath, which no binding of its own can tell it.
|
||||||
thisMachine := machineFacts(r, with.Names, with.MeshRange)
|
thisMachine := machineFacts(r, with.Names, with.MeshRange)
|
||||||
@@ -721,13 +698,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
// Said in the catalogue, not on the machine: the host parses strictly and knows no
|
// Said in the catalogue, not on the machine: the host parses strictly and knows no
|
||||||
// such field, and the reason is for a reader of the manifest.
|
// such field, and the reason is for a reader of the manifest.
|
||||||
delete(copied, SecretsInEnvironment)
|
delete(copied, SecretsInEnvironment)
|
||||||
delete(copied, NamesOnPurpose)
|
|
||||||
// **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a
|
|
||||||
// definition may not carry because it is true of one installation only. Filled from
|
|
||||||
// the same layers a mergeable file takes, and refused when no layer set it.
|
|
||||||
if err := settingInto(copied, with.Settings[m.Module], m.Module); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// **Placed before anything reads a path.** A pathless directory receives the path
|
// **Placed before anything reads a path.** A pathless directory receives the path
|
||||||
// this node resolves for it, and every ${dir:…} — in paths, mounts, content and
|
// this node resolves for it, and every ${dir:…} — in paths, mounts, content and
|
||||||
// environment — becomes that path, so what follows sees only concrete places
|
// environment — becomes that path, so what follows sees only concrete places
|
||||||
@@ -735,12 +705,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
if err := dirInto(copied, dirs, m.Module); err != nil {
|
if err := dirInto(copied, dirs, m.Module); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// The operator's data the same way: ${access:…} becomes where this node keeps it,
|
|
||||||
// and a placed directory takes the owner the assignment said (issue 153).
|
|
||||||
if err := accessInto(copied, accessPaths, m.Module); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ownerInto(copied, placed)
|
|
||||||
// **After settings, and that is the whole reason it is here.** A module's file
|
// **After settings, and that is the whole reason it is here.** A module's file
|
||||||
// content is where a setting lands, so a placeholder may only exist once the setting
|
// content is where a setting lands, so a placeholder may only exist once the setting
|
||||||
// has been put in — filling secrets first would look at content that is not yet what
|
// has been put in — filling secrets first would look at content that is not yet what
|
||||||
@@ -1158,7 +1122,7 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
// Settings reach a contribution the same way they reach a file. A route's hostname is
|
// Settings reach a contribution the same way they reach a file. A route's hostname is
|
||||||
// exactly the kind of thing that differs between one mesh and the next, and a module
|
// exactly the kind of thing that differs between one mesh and the next, and a module
|
||||||
// that could not have it set would have to be edited to be reused.
|
// that could not have it set would have to be edited to be reused.
|
||||||
values, err := r.composed(m, to, m.Contributes[to], settings[m.Module],
|
values, err := r.composed(m, m.Contributes[to], settings[m.Module],
|
||||||
m.Module+" contributing to "+to)
|
m.Module+" contributing to "+to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1171,7 +1135,7 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
// name always reaches the provider from here.
|
// name always reaches the provider from here.
|
||||||
for _, to := range sortedKeys(m.ContributesMany) {
|
for _, to := range sortedKeys(m.ContributesMany) {
|
||||||
for _, local := range sortedKeys(m.ContributesMany[to]) {
|
for _, local := range sortedKeys(m.ContributesMany[to]) {
|
||||||
values, err := r.composed(m, to, m.ContributesMany[to][local], settings[m.Module],
|
values, err := r.composed(m, m.ContributesMany[to][local], settings[m.Module],
|
||||||
m.Module+" contributing "+local+" to "+to)
|
m.Module+" contributing "+local+" to "+to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1190,12 +1154,9 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
// file; the contributing module is told the same names in its own binding (novox/hq 04-ISSUES/122).
|
// file; the contributing module is told the same names in its own binding (novox/hq 04-ISSUES/122).
|
||||||
// Composing them twice, in two places, is how the proxy would come to serve one name while the
|
// Composing them twice, in two places, is how the proxy would come to serve one name while the
|
||||||
// module wrote another into its configuration.
|
// module wrote another into its configuration.
|
||||||
func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers []Layer, what string) (
|
func (r Resolution) composed(m Manifest, raw map[string]any, layers []Layer, what string) (
|
||||||
map[string]any, error) {
|
map[string]any, error) {
|
||||||
// Overridden, not merged: a setting changes a key the contribution declares and adds none.
|
values, err := settle(raw, layers, nil, what)
|
||||||
// The provider reads the contribution as a contract, and a setting made for one of this
|
|
||||||
// module's files is no part of it (novox/hq 04-ISSUES/173).
|
|
||||||
values, err := overridden(raw, layers, what)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", what, err)
|
return nil, fmt.Errorf("%s: %w", what, err)
|
||||||
}
|
}
|
||||||
@@ -1208,7 +1169,7 @@ func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers [
|
|||||||
return nil, fmt.Errorf("%s: %w", what, err)
|
return nil, fmt.Errorf("%s: %w", what, err)
|
||||||
}
|
}
|
||||||
portOfEndpoint(values, endpointPorts(m))
|
portOfEndpoint(values, endpointPorts(m))
|
||||||
composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks)
|
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
|
||||||
return values, nil
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,7 +1196,7 @@ func (r Resolution) ownNames(m Manifest, to string, layers []Layer) (map[string]
|
|||||||
}
|
}
|
||||||
out := map[string]any{}
|
out := map[string]any{}
|
||||||
if raw, ok := m.Contributes[to]; ok {
|
if raw, ok := m.Contributes[to]; ok {
|
||||||
values, err := r.composed(m, to, raw, layers, m.Module+" contributing to "+to)
|
values, err := r.composed(m, raw, layers, m.Module+" contributing to "+to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1246,7 +1207,7 @@ func (r Resolution) ownNames(m Manifest, to string, layers []Layer) (map[string]
|
|||||||
if locals := m.ContributesMany[to]; len(locals) > 0 {
|
if locals := m.ContributesMany[to]; len(locals) > 0 {
|
||||||
many := map[string]any{}
|
many := map[string]any{}
|
||||||
for _, local := range sortedKeys(locals) {
|
for _, local := range sortedKeys(locals) {
|
||||||
values, err := r.composed(m, to, locals[local], layers,
|
values, err := r.composed(m, locals[local], layers,
|
||||||
m.Module+" contributing "+local+" to "+to)
|
m.Module+" contributing "+local+" to "+to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1354,24 +1315,6 @@ func composeName(values map[string]any, publicDomain, internalDomain string, rea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// servingAt is the private-network name of the node a contribution to `to` arrives at: the
|
|
||||||
// provider's, when the provision is answered elsewhere, and this machine's own when it is answered
|
|
||||||
// here or not yet settled.
|
|
||||||
//
|
|
||||||
// A route's internal name is composed under it (novox/hq ADR 0151, issue 139). `<label>.<node>.internal`
|
|
||||||
// is answered by every machine's resolver as *anything under that node's name goes to that node* —
|
|
||||||
// so the node in the name has to be the one whose proxy answers, or the name sends a client to a
|
|
||||||
// machine with nothing listening while the public name, published at the serving node's address,
|
|
||||||
// works. Where the proxy runs beside the module the two are the same machine and nothing changes.
|
|
||||||
func servingAt(r Resolution, to string) string {
|
|
||||||
for _, n := range r.Needs {
|
|
||||||
if n.Name == to && n.At != "" {
|
|
||||||
return n.At
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return r.At
|
|
||||||
}
|
|
||||||
|
|
||||||
// receivedFile is the file a provider is given its consumers' contributions in.
|
// receivedFile is the file a provider is given its consumers' contributions in.
|
||||||
func receivedFile(requirement, path string, given []Contribution) (map[string]any, error) {
|
func receivedFile(requirement, path string, given []Contribution) (map[string]any, error) {
|
||||||
if given == nil {
|
if given == nil {
|
||||||
@@ -1635,6 +1578,43 @@ func (r Resolution) servedOnThisMachine(provision string, with Rendering) (map[s
|
|||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withMeshNames gives every container in a set the mesh's names.
|
||||||
|
//
|
||||||
|
// Copied rather than edited in place: these maps come from a module's manifest, and mutating one
|
||||||
|
// would change what the catalogue holds for every other machine running that module.
|
||||||
|
//
|
||||||
|
// A host-network container gets the names too. It was once skipped, on the belief that it "shares
|
||||||
|
// the machine's hosts file already" — but it does not: `docker run --network host` still gives the
|
||||||
|
// container its own /etc/hosts (localhost and its own id only), so every `<node>.internal` name the
|
||||||
|
// mesh wrote for the machine is invisible inside it, and a client that dials one gets EAI_AGAIN. The
|
||||||
|
// remedy is the same `--add-host` every other container gets — the runtime accepts it with
|
||||||
|
// `--network host` (verified), and without it a host-network consumer cannot reach a provider by the
|
||||||
|
// `.internal` address the mesh hands it as `${bound:...:at}`.
|
||||||
|
func withMeshNames(resources []map[string]any, names map[string]string) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(resources))
|
||||||
|
for _, r := range resources {
|
||||||
|
if r["type"] != "container" {
|
||||||
|
out = append(out, r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
copied := map[string]any{}
|
||||||
|
for k, v := range r {
|
||||||
|
copied[k] = v
|
||||||
|
}
|
||||||
|
var given []any
|
||||||
|
if already, ok := copied["hosts"].([]any); ok {
|
||||||
|
given = append(given, already...)
|
||||||
|
}
|
||||||
|
for _, name := range sortedKeys(names) {
|
||||||
|
given = append(given, name+":"+names[name])
|
||||||
|
}
|
||||||
|
copied["hosts"] = given
|
||||||
|
out = append(out, copied)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// pinned refuses an image that is not really pinned, on its way to a machine.
|
// pinned refuses an image that is not really pinned, on its way to a machine.
|
||||||
//
|
//
|
||||||
// **Here and not at parse** (novox/hq 04-ISSUES/025). A manifest in a repository names artifacts
|
// **Here and not at parse** (novox/hq 04-ISSUES/025). A manifest in a repository names artifacts
|
||||||
@@ -1713,23 +1693,14 @@ func publishedOn(resource map[string]any, module string, with Rendering) {
|
|||||||
out = append(out, givenOuter(written, with.Given[module]))
|
out = append(out, givenOuter(written, with.Given[module]))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// A short form may carry the protocol — `"3478/udp"` — and the number is what the mesh
|
wanted, err := strconv.Atoi(strings.TrimSpace(written))
|
||||||
// assigns for; the protocol rides along. Read as one token, the `/udp` made the whole
|
|
||||||
// entry "not a port", and passing it through let the runtime publish it wherever it
|
|
||||||
// liked: unifi's STUN and discovery landed on random machine ports while every TCP pin
|
|
||||||
// beside them held.
|
|
||||||
mapping, protocol := written, ""
|
|
||||||
if cut := strings.LastIndex(written, "/"); cut >= 0 {
|
|
||||||
mapping, protocol = written[:cut], written[cut:]
|
|
||||||
}
|
|
||||||
wanted, err := strconv.Atoi(strings.TrimSpace(mapping))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Not a port at all. Passed through, so the host refuses it with its own words rather
|
// Not a port at all. Passed through, so the host refuses it with its own words rather
|
||||||
// than this quietly dropping something somebody meant.
|
// than this quietly dropping something somebody meant.
|
||||||
out = append(out, written)
|
out = append(out, written)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, fmt.Sprintf("%d:%d%s", with.machinePort(module, wanted), wanted, protocol))
|
out = append(out, fmt.Sprintf("%d:%d", with.machinePort(module, wanted), wanted))
|
||||||
}
|
}
|
||||||
resource["ports"] = out
|
resource["ports"] = out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,6 @@ import (
|
|||||||
// declared with the path as the exception it is, and everything else in the module names it by
|
// declared with the path as the exception it is, and everything else in the module names it by
|
||||||
// id — so moving it later is one line, not a search.
|
// id — so moving it later is one line, not a search.
|
||||||
//
|
//
|
||||||
// **The mesh's own files for a module are placed too** (novox/hq issue 174). What the mesh writes
|
|
||||||
// *for* a module — its sealed bus credential, its merged configuration, its bindings — is the
|
|
||||||
// mesh's plumbing, not the module's data, and sits under `<root>/mesh/<module>`. A directory
|
|
||||||
// saying `"place": "mesh"` is that place; the definition names the files beneath it by
|
|
||||||
// `${dir:<id>}` and states no path.
|
|
||||||
//
|
|
||||||
// **Resolved here, not on the machine.** The host receives concrete paths exactly as it always
|
// **Resolved here, not on the machine.** The host receives concrete paths exactly as it always
|
||||||
// has; nothing new reaches it and it learns no field. Which also means a resolved path changing
|
// has; nothing new reaches it and it learns no field. Which also means a resolved path changing
|
||||||
// is a spec change like any other — and the spec comparison must see it (novox/hq issue 126).
|
// is a spec change like any other — and the spec comparison must see it (novox/hq issue 126).
|
||||||
@@ -33,15 +27,6 @@ import (
|
|||||||
// defaultDataRoot is where module data lands when a node states no root of its own.
|
// defaultDataRoot is where module data lands when a node states no root of its own.
|
||||||
const defaultDataRoot = "/var/lib"
|
const defaultDataRoot = "/var/lib"
|
||||||
|
|
||||||
// The two places a pathless directory may name, beside its own id.
|
|
||||||
const (
|
|
||||||
// placeOwn is the assignment's own root, <root>/<module> — to-be 27's one directory per
|
|
||||||
// assignment, which every other placed thing of the module sits beneath.
|
|
||||||
placeOwn = "."
|
|
||||||
// placeMesh is where the mesh keeps what it writes for the module, <root>/mesh/<module>.
|
|
||||||
placeMesh = "mesh"
|
|
||||||
)
|
|
||||||
|
|
||||||
// dirRef is how a module names one of its placed directories: ${dir:<id>}.
|
// dirRef is how a module names one of its placed directories: ${dir:<id>}.
|
||||||
var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
|
var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
|
||||||
|
|
||||||
@@ -55,53 +40,26 @@ func dataRoot(with Rendering) string {
|
|||||||
|
|
||||||
// dirsFor is every placed directory of a module, id → the path it resolves to on this node.
|
// dirsFor is every placed directory of a module, id → the path it resolves to on this node.
|
||||||
//
|
//
|
||||||
// A pathless directory saying `"place": "."` is the assignment's own root, <root>/<module>; one
|
// A pathless directory saying `"place": "."` is the assignment's own root, <root>/<module> —
|
||||||
// saying `"place": "mesh"` is the mesh's directory for the module, <root>/mesh/<module>; one
|
// to-be 27's one directory per assignment, which every other placed thing sits beneath. At most
|
||||||
// saying neither is <root>/<module>/<id>. At most one of each place makes sense; nothing enforces
|
// one makes sense; nothing enforces one, because two ids resolving to one path is a mistake the
|
||||||
// one, because two ids resolving to one path is a mistake the module's own files make visible
|
// module's own files make visible immediately.
|
||||||
// immediately.
|
|
||||||
//
|
|
||||||
// A stated path may itself begin with a placed reference — `${dir:mesh-state}/state` — and is
|
|
||||||
// filled after the directories it can name are resolved; one level, because a directory beneath
|
|
||||||
// a placed one is the whole of what an adopted layout needs (issue 174's `state` and `out`).
|
|
||||||
func dirsFor(m Manifest, with Rendering) map[string]string {
|
func dirsFor(m Manifest, with Rendering) map[string]string {
|
||||||
dirs := map[string]string{}
|
dirs := map[string]string{}
|
||||||
var beneath []map[string]any
|
|
||||||
// The assignment's placement wins over both (novox/hq issue 153). Refused elsewhere when
|
|
||||||
// malformed; here an invalid setting simply places nothing.
|
|
||||||
placed, _ := Places(m, with.Settings[m.Module])
|
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
if fmt.Sprint(r["type"]) != "directory" {
|
if fmt.Sprint(r["type"]) != "directory" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id := fmt.Sprint(r["id"])
|
id := fmt.Sprint(r["id"])
|
||||||
if p, said := placed[id]; said {
|
|
||||||
dirs[id] = p.Path
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if path, stated := r["path"].(string); stated && path != "" {
|
if path, stated := r["path"].(string); stated && path != "" {
|
||||||
if strings.HasPrefix(path, "${dir:") {
|
|
||||||
beneath = append(beneath, r)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dirs[id] = strings.TrimRight(path, "/")
|
dirs[id] = strings.TrimRight(path, "/")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch place, _ := r["place"].(string); place {
|
if place, said := r["place"].(string); said && place == "." {
|
||||||
case placeOwn:
|
|
||||||
dirs[id] = dataRoot(with) + "/" + m.Module
|
dirs[id] = dataRoot(with) + "/" + m.Module
|
||||||
case placeMesh:
|
continue
|
||||||
dirs[id] = dataRoot(with) + "/mesh/" + m.Module
|
|
||||||
default:
|
|
||||||
dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id
|
|
||||||
}
|
}
|
||||||
}
|
dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id
|
||||||
for _, r := range beneath {
|
|
||||||
path := strings.TrimRight(r["path"].(string), "/")
|
|
||||||
// A reference to no directory is left as written and refused where the resource is
|
|
||||||
// placed (dirInto), with the message that names what exists.
|
|
||||||
filled, _ := dirFill(path, dirs, m.Module)
|
|
||||||
dirs[fmt.Sprint(r["id"])] = filled
|
|
||||||
}
|
}
|
||||||
return dirs
|
return dirs
|
||||||
}
|
}
|
||||||
@@ -161,16 +119,8 @@ func placedManifest(m Manifest, with Rendering) (Manifest, error) {
|
|||||||
if m.Secrets, err = fillMap(m.Secrets); err != nil {
|
if m.Secrets, err = fillMap(m.Secrets); err != nil {
|
||||||
return m, err
|
return m, err
|
||||||
}
|
}
|
||||||
if len(m.OwnSecrets) > 0 {
|
if m.OwnSecrets, err = fillMap(m.OwnSecrets); err != nil {
|
||||||
own := make(OwnSecrets, len(m.OwnSecrets))
|
return m, err
|
||||||
for name, s := range m.OwnSecrets {
|
|
||||||
filled, err := dirFill(s.Path, dirs, m.Module)
|
|
||||||
if err != nil {
|
|
||||||
return m, err
|
|
||||||
}
|
|
||||||
own[name] = OwnSecret{Path: filled, Taken: s.Taken}
|
|
||||||
}
|
|
||||||
m.OwnSecrets = own
|
|
||||||
}
|
}
|
||||||
if m.Grants, err = fillMap(m.Grants); err != nil {
|
if m.Grants, err = fillMap(m.Grants); err != nil {
|
||||||
return m, err
|
return m, err
|
||||||
@@ -294,11 +244,10 @@ func (m Manifest) unknownDirRefs() []string {
|
|||||||
"%s states both path and place on %v — a stated path IS the placement",
|
"%s states both path and place on %v — a stated path IS the placement",
|
||||||
m.Module, r["id"]))
|
m.Module, r["id"]))
|
||||||
}
|
}
|
||||||
if place != placeOwn && place != placeMesh {
|
if place != "." {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s says place %q on %v, and the places are %q — the assignment's own root — and "+
|
"%s says place %q on %v, and the only place is %q — the assignment's own root",
|
||||||
"%q — where the mesh keeps what it writes for the module",
|
m.Module, place, r["id"], "."))
|
||||||
m.Module, place, r["id"], placeOwn, placeMesh))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
@@ -343,7 +292,7 @@ func (m Manifest) unknownDirRefs() []string {
|
|||||||
}
|
}
|
||||||
maps := map[string]map[string]string{
|
maps := map[string]map[string]string{
|
||||||
"receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets,
|
"receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets,
|
||||||
"own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants,
|
"own-secrets": m.OwnSecrets, "grants": m.Grants,
|
||||||
}
|
}
|
||||||
for field, entries := range maps {
|
for field, entries := range maps {
|
||||||
for _, value := range entries {
|
for _, value := range entries {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ func TestTheManifestsMapsArePlaced(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Binds: map[string]string{"route": "${dir:state}/route.json"},
|
Binds: map[string]string{"route": "${dir:state}/route.json"},
|
||||||
Secrets: map[string]string{"mongodb-database": "${dir:state}/database.secret"},
|
Secrets: map[string]string{"mongodb-database": "${dir:state}/database.secret"},
|
||||||
OwnSecrets: OwnSecrets{"admin-key": {Path: "${dir:state}/admin-key.secret"}},
|
OwnSecrets: map[string]string{"admin-key": "${dir:state}/admin-key.secret"},
|
||||||
Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"},
|
Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"},
|
||||||
}
|
}
|
||||||
placed, err := placedManifest(m, Rendering{})
|
placed, err := placedManifest(m, Rendering{})
|
||||||
@@ -177,7 +177,7 @@ func TestTheManifestsMapsArePlaced(t *testing.T) {
|
|||||||
if placed.Secrets["mongodb-database"] != "/var/lib/photos/database.secret" {
|
if placed.Secrets["mongodb-database"] != "/var/lib/photos/database.secret" {
|
||||||
t.Fatalf("secrets are placed; got %v", placed.Secrets)
|
t.Fatalf("secrets are placed; got %v", placed.Secrets)
|
||||||
}
|
}
|
||||||
if placed.OwnSecrets["admin-key"].Path != "/var/lib/photos/admin-key.secret" {
|
if placed.OwnSecrets["admin-key"] != "/var/lib/photos/admin-key.secret" {
|
||||||
t.Fatalf("own-secrets are placed; got %v", placed.OwnSecrets)
|
t.Fatalf("own-secrets are placed; got %v", placed.OwnSecrets)
|
||||||
}
|
}
|
||||||
if placed.Receives["route"] != "/var/lib/photos/grants/mesh.json" {
|
if placed.Receives["route"] != "/var/lib/photos/grants/mesh.json" {
|
||||||
@@ -216,48 +216,8 @@ func TestPlaceIsValidatedAtTheManifest(t *testing.T) {
|
|||||||
wrong := Manifest{Module: "x", Resources: []map[string]any{
|
wrong := Manifest{Module: "x", Resources: []map[string]any{
|
||||||
{"id": "d", "type": "directory", "place": "sub/dir"},
|
{"id": "d", "type": "directory", "place": "sub/dir"},
|
||||||
}}
|
}}
|
||||||
if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) {
|
if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the only place is "."`) {
|
||||||
t.Fatalf("a place that is neither root refuses; got %v", got)
|
t.Fatalf("a place that is not the root refuses; got %v", got)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
|
|
||||||
// novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings —
|
|
||||||
// is the mesh's plumbing under <root>/mesh/<module>, and the definition names it by id.
|
|
||||||
m := Manifest{Module: "umami",
|
|
||||||
Resources: []map[string]any{
|
|
||||||
{"id": "mesh-state", "type": "directory", "place": "mesh"},
|
|
||||||
{"id": "state", "type": "directory", "place": "."},
|
|
||||||
{"id": "server", "type": "container", "image": "x@sha256:aa",
|
|
||||||
"volumes": []any{"${dir:mesh-state}/broker:/run/secrets/broker:ro"}},
|
|
||||||
},
|
|
||||||
OwnSecrets: OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}},
|
|
||||||
Binds: map[string]string{"route": "${dir:state}/route.json"},
|
|
||||||
}
|
|
||||||
if got := m.unknownDirRefs(); len(got) != 0 {
|
|
||||||
t.Fatalf("place %q is a place; got %v", "mesh", got)
|
|
||||||
}
|
|
||||||
dirs := dirsFor(m, Rendering{})
|
|
||||||
if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" {
|
|
||||||
t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs)
|
|
||||||
}
|
|
||||||
dirs = dirsFor(m, Rendering{DataRoot: "/srv"})
|
|
||||||
if dirs["mesh-state"] != "/srv/mesh/umami" {
|
|
||||||
t.Fatalf("a node's root moves the mesh's files with the module's; got %v", dirs)
|
|
||||||
}
|
|
||||||
placed, err := placedManifest(m, Rendering{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if placed.OwnSecrets["broker"].Path != "/var/lib/mesh/umami/broker" {
|
|
||||||
t.Fatalf("own-secrets are placed under the mesh's directory; got %v", placed.OwnSecrets)
|
|
||||||
}
|
|
||||||
container := shallowCopy(m.Resources[2])
|
|
||||||
if err := dirInto(container, dirsFor(m, Rendering{}), m.Module); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if container["volumes"].([]any)[0] != "/var/lib/mesh/umami/broker:/run/secrets/broker:ro" {
|
|
||||||
t.Fatalf("the mount's host side is placed; got %v", container["volumes"])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,36 +250,3 @@ func shallowCopy(resource map[string]any) map[string]any {
|
|||||||
}
|
}
|
||||||
return copied
|
return copied
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) {
|
|
||||||
// An adopted layout keeps a subdirectory the predecessor made under the mesh's directory
|
|
||||||
// (issue 174: a forge's runtime state, a manager's output). Stated as beneath the placed one,
|
|
||||||
// it moves with it — a node's root moves both, and the definition names no host path.
|
|
||||||
m := Manifest{Module: "gitea", Resources: []map[string]any{
|
|
||||||
{"id": "mesh-state", "type": "directory", "place": "mesh"},
|
|
||||||
{"id": "runtime-state", "type": "directory", "path": "${dir:mesh-state}/state"},
|
|
||||||
{"id": "server", "type": "container", "image": "x@sha256:aa",
|
|
||||||
"volumes": []any{"${dir:runtime-state}:/data"}},
|
|
||||||
}}
|
|
||||||
if got := m.unknownDirRefs(); len(got) != 0 {
|
|
||||||
t.Fatalf("a path beneath a placed directory is well formed; got %v", got)
|
|
||||||
}
|
|
||||||
dirs := dirsFor(m, Rendering{DataRoot: "/srv"})
|
|
||||||
if dirs["runtime-state"] != "/srv/mesh/gitea/state" {
|
|
||||||
t.Fatalf("the subdirectory follows the placed one; got %v", dirs)
|
|
||||||
}
|
|
||||||
sub := shallowCopy(m.Resources[1])
|
|
||||||
if err := dirInto(sub, dirs, m.Module); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if sub["path"] != "/srv/mesh/gitea/state" {
|
|
||||||
t.Fatalf("the directory resource itself is resolved; got %v", sub["path"])
|
|
||||||
}
|
|
||||||
container := shallowCopy(m.Resources[2])
|
|
||||||
if err := dirInto(container, dirs, m.Module); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if container["volumes"].([]any)[0] != "/srv/mesh/gitea/state:/data" {
|
|
||||||
t.Fatalf("a reference to the subdirectory resolves whole; got %v", container["volumes"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ func TestWhatTheMeshComputesIsAppliedBeforeWhatTheModuleDeclared(t *testing.T) {
|
|||||||
func TestAComputedModuleStillGetsWhatTheMeshMadeForIt(t *testing.T) {
|
func TestAComputedModuleStillGetsWhatTheMeshMadeForIt(t *testing.T) {
|
||||||
r := Resolution{Modules: []Manifest{{
|
r := Resolution{Modules: []Manifest{{
|
||||||
Module: "networking", Computed: "mesh-network",
|
Module: "networking", Computed: "mesh-network",
|
||||||
OwnSecrets: OwnSecrets{"key": {Path: "/var/lib/mesh/key"}},
|
OwnSecrets: map[string]string{"key": "/var/lib/mesh/key"},
|
||||||
}}}
|
}}}
|
||||||
out, err := r.Declaration(Rendering{
|
out, err := r.Declaration(Rendering{
|
||||||
Needed: map[string]map[string]string{"networking": {"key": "sealed"}},
|
Needed: map[string]map[string]string{"networking": {"key": "sealed"}},
|
||||||
|
|||||||
@@ -107,27 +107,6 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
|
|||||||
if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) {
|
if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) {
|
||||||
t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err)
|
t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err)
|
||||||
}
|
}
|
||||||
// A holder's own tools need not be the seat's verbs: the claim may name what it serves for the
|
|
||||||
// role (ADR 0160), and then only those count — and only the seat's verbs may be named.
|
|
||||||
promising := seat
|
|
||||||
promising.Serves = []Verb{{Name: "databases"}, {Name: "query"}}
|
|
||||||
engine := newBroker()
|
|
||||||
engine.Tools = []string{"engine_list_databases", "engine_query"}
|
|
||||||
if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not serve databases, query") {
|
|
||||||
t.Fatalf("a holder whose tools are not the seat's verbs was allowed without saying what it serves: %v", err)
|
|
||||||
}
|
|
||||||
engine.Claims[0].Serves = []string{"databases", "query"}
|
|
||||||
if err := CanHold(engine, promising); err != nil {
|
|
||||||
t.Fatalf("a claim naming the seat's verbs was refused: %v", err)
|
|
||||||
}
|
|
||||||
engine.Claims[0].Serves = []string{"databases"}
|
|
||||||
if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not serve query") {
|
|
||||||
t.Fatalf("a claim naming half the verbs was allowed: %v", err)
|
|
||||||
}
|
|
||||||
engine.Claims[0].Serves = []string{"databases", "query", "engine_query"}
|
|
||||||
if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not promise") {
|
|
||||||
t.Fatalf("a claim naming a verb the seat never promised was allowed: %v", err)
|
|
||||||
}
|
|
||||||
// And the judgement follows the store's row, not a compiled copy.
|
// And the judgement follows the store's row, not a compiled copy.
|
||||||
busSeatDelivering(t, "amqp")
|
busSeatDelivering(t, "amqp")
|
||||||
seat, _ = SeatNamed("mesh-broker")
|
seat, _ = SeatNamed("mesh-broker")
|
||||||
@@ -179,16 +158,3 @@ func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) {
|
|||||||
t.Fatalf("the store's own columns were not kept: %+v", got)
|
t.Fatalf("the store's own columns were not kept: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestARefusalWithNothingOnRecordNamesTheHandover(t *testing.T) {
|
|
||||||
busSeatDelivering(t, "mesh-bus")
|
|
||||||
elsewhere := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "old-broker"}}
|
|
||||||
|
|
||||||
_, problems := checkClaims([]Manifest{newBroker()}, Node{Name: "laptop"}, elsewhere, nil)
|
|
||||||
if len(problems) != 1 {
|
|
||||||
t.Fatalf("two derived claimants across machines were not refused: %v", problems)
|
|
||||||
}
|
|
||||||
if !strings.Contains(problems[0], "`seat mesh-broker --to anchor/old-broker`") {
|
|
||||||
t.Fatalf("the refusal does not name the handover that records the holder: %s", problems[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134).
|
|
||||||
//
|
|
||||||
// A module definition holds what is true of the module everywhere; what is particular to one mesh —
|
|
||||||
// a public name, a forge's address, a node's public address — is resolved at assignment. The rule
|
|
||||||
// stood for a month with nothing checking it, and a sweep found thirty of seventy-one definitions
|
|
||||||
// naming the installation they were written in. This is the check.
|
|
||||||
//
|
|
||||||
// **What is judged is what the mesh acts on, not what a person reads.** A domain in a `why` or a
|
|
||||||
// `description` is documentation the mesh never reads; reporting it beside `KC_HOSTNAME` would teach
|
|
||||||
// people to ignore the report. What is judged is every other string value: a name under a public
|
|
||||||
// top-level domain, or a public address. Two families of name are the world's and not this mesh's,
|
|
||||||
// and are allowed where they can only mean the world: the public registries an `image` may be pulled
|
|
||||||
// from, and the public resolvers a machine may forward to. The container runtime's own alias for
|
|
||||||
// its host is the runtime's, true on every machine that runs it.
|
|
||||||
//
|
|
||||||
// **A name that is right where it stands is declared, one by one, with its reason.** A federated
|
|
||||||
// server's config names the federation's public directory; an application built outside the mesh
|
|
||||||
// is pulled from the registry that built it, until the mesh builds it. The resource carries
|
|
||||||
// `names-on-purpose`, a map from each such name to why — the shape `secrets-in-environment` has,
|
|
||||||
// per name — so a reader sees which names a definition means to carry and why, a name the map does
|
|
||||||
// not cover is still reported, and the catalogue-wide test is the list that shrinks as names move.
|
|
||||||
|
|
||||||
// NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name:
|
|
||||||
// each name mapped to its reason. The host never sees it.
|
|
||||||
const NamesOnPurpose = "names-on-purpose"
|
|
||||||
|
|
||||||
// prose is every key whose value the mesh never reads.
|
|
||||||
var prose = map[string]bool{"why": true, "description": true}
|
|
||||||
|
|
||||||
// Registries the world runs, which an image may name because an image reference must say where it
|
|
||||||
// is pulled from. Anything else in an image reference is a registry of some installation.
|
|
||||||
var worldsRegistries = map[string]bool{
|
|
||||||
"docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true,
|
|
||||||
"quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true,
|
|
||||||
"mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true,
|
|
||||||
"codeberg.org": true, "cgr.dev": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Services the world runs that a definition may name as a policy default, the way it may name a
|
|
||||||
// public resolver: the public certificate authorities' ACME directories. Anything else a served
|
|
||||||
// fact or a file names is somebody's installation.
|
|
||||||
var worldsServices = map[string]bool{
|
|
||||||
"acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true,
|
|
||||||
"api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true,
|
|
||||||
"acme.zerossl.com": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolvers the world runs, which a machine's resolver may forward to as a policy default.
|
|
||||||
var worldsResolvers = map[string]bool{
|
|
||||||
"1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true,
|
|
||||||
"149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// hostname is a dotted name whose last label is a top-level domain a real installation would have.
|
|
||||||
// Not every dotted token: `module.json`, `index.html` and `docker.sock` are dotted and name nothing.
|
|
||||||
// Boundaries are checked by hand rather than in the pattern, because two names one character apart
|
|
||||||
// — `a.example.tld,b.example.tld` — would otherwise share the delimiter and the second would be lost.
|
|
||||||
var hostname = regexp.MustCompile(
|
|
||||||
`(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` +
|
|
||||||
`(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` +
|
|
||||||
`internal|example|tld|test|invalid)`)
|
|
||||||
|
|
||||||
// address is a dotted quad.
|
|
||||||
var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
|
|
||||||
|
|
||||||
// isName is whether a byte may be part of a name; a match bordered by one is a longer token.
|
|
||||||
func isName(b byte) bool {
|
|
||||||
return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
|
|
||||||
}
|
|
||||||
|
|
||||||
// standalone are the matches of re in value that are whole tokens, not parts of a longer one.
|
|
||||||
func standalone(re *regexp.Regexp, value string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, span := range re.FindAllStringIndex(value, -1) {
|
|
||||||
if span[0] > 0 && isName(value[span[0]-1]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if span[1] < len(value) && isName(value[span[1]]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, value[span[0]:span[1]])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstallationProblems is every value of a definition that names an installation, in the
|
|
||||||
// definition's own words: where it is, and what it names.
|
|
||||||
func InstallationProblems(m Manifest) []string {
|
|
||||||
raw, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
|
|
||||||
}
|
|
||||||
var tree any
|
|
||||||
if err := json.Unmarshal(raw, &tree); err != nil {
|
|
||||||
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
// The module's own name is a value too: a module named after the domain it serves is a
|
|
||||||
// definition that can only be installed there (issue 134).
|
|
||||||
for _, name := range namesIn(m.Module) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name))
|
|
||||||
}
|
|
||||||
walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) {
|
|
||||||
for _, name := range namesIn(value) {
|
|
||||||
if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at))
|
|
||||||
}
|
|
||||||
for _, ip := range addressesIn(value) {
|
|
||||||
if meant[ip] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
sort.Strings(problems)
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// walk visits every string in the tree with its path, the names the enclosing resource means to
|
|
||||||
// name (with a reason), and whether it is an image reference.
|
|
||||||
func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) {
|
|
||||||
switch v := node.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
if declared, has := v[NamesOnPurpose].(map[string]any); has {
|
|
||||||
widened := map[string]bool{}
|
|
||||||
for name := range meant {
|
|
||||||
widened[name] = true
|
|
||||||
}
|
|
||||||
for name, reason := range declared {
|
|
||||||
if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" {
|
|
||||||
widened[strings.ToLower(name)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
meant = widened
|
|
||||||
}
|
|
||||||
keys := make([]string, 0, len(v))
|
|
||||||
for k := range v {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
for _, k := range keys {
|
|
||||||
if prose[k] || k == NamesOnPurpose || (at == "" && k == "module") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
child := at + "." + k
|
|
||||||
if at == "" {
|
|
||||||
child = k
|
|
||||||
}
|
|
||||||
if s, isString := v[k].(string); isString {
|
|
||||||
visit(child, s, meant, k == "image")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
walk(v[k], child, meant, visit)
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for i, item := range v {
|
|
||||||
child := fmt.Sprintf("%s[%d]", at, i)
|
|
||||||
if s, isString := item.(string); isString {
|
|
||||||
visit(child, s, meant, false)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
walk(item, child, meant, visit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// namesIn is every hostname in a value that could belong to an installation.
|
|
||||||
func namesIn(value string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, found := range standalone(hostname, value) {
|
|
||||||
name := strings.ToLower(found)
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(name, ".docker.internal"):
|
|
||||||
// The container runtime's alias for its own host: every machine running it has one.
|
|
||||||
case worldsServices[name]:
|
|
||||||
// A public authority named as a policy default, true of any mesh that wants it.
|
|
||||||
case name == "example.tld", strings.HasSuffix(name, ".example.tld"),
|
|
||||||
name == "example.com", name == "example.net", name == "example.org",
|
|
||||||
strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"),
|
|
||||||
strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"),
|
|
||||||
strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"):
|
|
||||||
// Documentation names, which is what a definition's own example should use.
|
|
||||||
default:
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// addressesIn is every public address in a value: not a private range, loopback, link-local, the
|
|
||||||
// unspecified address, a documentation range, or a resolver the world runs.
|
|
||||||
func addressesIn(value string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, found := range standalone(address, value) {
|
|
||||||
ip := net.ParseIP(found)
|
|
||||||
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
|
|
||||||
ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, found)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func documentation(ip net.IP) bool {
|
|
||||||
for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} {
|
|
||||||
_, block, _ := net.ParseCIDR(cidr)
|
|
||||||
if block.Contains(ip) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged;
|
|
||||||
// prose is not; the world's registries and resolvers are the world's; a declared exception is a
|
|
||||||
// reason a reader sees.
|
|
||||||
func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) {
|
|
||||||
m := Manifest{Module: "idp", Resources: []map[string]any{
|
|
||||||
{"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa",
|
|
||||||
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
|
|
||||||
{"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"},
|
|
||||||
}, Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}}
|
|
||||||
got := strings.Join(InstallationProblems(m), "\n")
|
|
||||||
for _, want := range []string{
|
|
||||||
"idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME",
|
|
||||||
"idp names the public address 51.15.22.9 at resources[1].content",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Errorf("missing %q in:\n%s", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} {
|
|
||||||
if strings.Contains(got, mustNot) {
|
|
||||||
t.Errorf("%q was reported and should not be:\n%s", mustNot, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) {
|
|
||||||
m := Manifest{Module: "resolver", Resources: []map[string]any{
|
|
||||||
{"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"},
|
|
||||||
{"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"},
|
|
||||||
{"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"},
|
|
||||||
{"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"},
|
|
||||||
}}
|
|
||||||
if got := InstallationProblems(m); len(got) != 0 {
|
|
||||||
t.Fatalf("the world's names were reported: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) {
|
|
||||||
// The federation's public directory in a homeserver's config: the world's, said so, and a name
|
|
||||||
// the map does not cover is still reported.
|
|
||||||
m := Manifest{Module: "homeserver", Resources: []map[string]any{
|
|
||||||
{"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n",
|
|
||||||
NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}},
|
|
||||||
}}
|
|
||||||
got := InstallationProblems(m)
|
|
||||||
if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") {
|
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) {
|
|
||||||
bare := Manifest{Module: "site", Resources: []map[string]any{
|
|
||||||
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"},
|
|
||||||
}}
|
|
||||||
if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") {
|
|
||||||
t.Fatalf("an image on an installation's registry was not named: %v", got)
|
|
||||||
}
|
|
||||||
excepted := Manifest{Module: "site", Resources: []map[string]any{
|
|
||||||
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
|
|
||||||
NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}},
|
|
||||||
}}
|
|
||||||
if got := InstallationProblems(excepted); len(got) != 0 {
|
|
||||||
t.Fatalf("a declared exception was still reported: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) {
|
|
||||||
got := InstallationProblems(Manifest{Module: "mesh-one.be"})
|
|
||||||
if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") {
|
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestABuildContextOnASeatNamesNoForge(t *testing.T) {
|
|
||||||
m := Manifest{Module: "packager", Build: &Build{Artifacts: []Artifact{
|
|
||||||
{Name: "server", Kind: "image", From: "Dockerfile",
|
|
||||||
Context: &ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}},
|
|
||||||
}}}
|
|
||||||
if got := InstallationProblems(m); len(got) != 0 {
|
|
||||||
t.Fatalf("a context on a seat was reported: %v", got)
|
|
||||||
}
|
|
||||||
m.Build.Artifacts[0].Context = &ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"}
|
|
||||||
if got := InstallationProblems(m); len(got) != 1 {
|
|
||||||
t.Fatalf("a context by URL was not reported: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A manifest may say which tools its module calls (novox/hq ADR 0152), and the parser accepts the
|
|
||||||
// two shapes the grant has: a named tool, and every tool.
|
|
||||||
func TestAManifestMaySayWhatItInvokes(t *testing.T) {
|
|
||||||
m, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1",` +
|
|
||||||
`"invokes":["mesh-catalog.catalog_modules","*"]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(m.Invokes) != 2 || m.Invokes[1] != "*" {
|
|
||||||
t.Fatalf("invokes not read: %v", m.Invokes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than
|
|
||||||
// at the composition of the bus's user list where it would stop the file for everybody.
|
|
||||||
func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("an invoke naming no tool was accepted")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) {
|
|
||||||
t.Fatalf("refused for the wrong reason: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-254
@@ -42,32 +42,11 @@ var renamed = map[string]string{
|
|||||||
|
|
||||||
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`)
|
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`)
|
||||||
|
|
||||||
// toolName is what a module calls one of its tools: the sdk's tools are `catalog_modules` and
|
|
||||||
// `gitea_list_repos`, so an underscore is ordinary here and a dot is not — the dot is what separates
|
|
||||||
// the module from the tool in `<module>.<tool>`, and a tool name carrying one would be two grants.
|
|
||||||
var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
|
||||||
|
|
||||||
// Claim is a singular resource a module takes over.
|
// Claim is a singular resource a module takes over.
|
||||||
type Claim struct {
|
type Claim struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// Scope defaults to the node, which is where nearly everything singular is singular.
|
// Scope defaults to the node, which is where nearly everything singular is singular.
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
// Serves names the seat's verbs this module implements for the role, when its own tools are
|
|
||||||
// not the seat's (novox/hq ADR 0159, 0160): the store's `databases` is not postgres's
|
|
||||||
// `postgres_list_databases`, and a holder may well serve both. The runtime serves an
|
|
||||||
// implementation registered under the seat's name on the seat's subjects. Absent, the
|
|
||||||
// module's own `tools` must list every verb the seat promises, which is how a module named
|
|
||||||
// like its seat — the catalogue, the records — says they are one and the same.
|
|
||||||
Serves []string `json:"serves,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's
|
|
||||||
// own tools.
|
|
||||||
func (c Claim) ServesFor(m Manifest) []string {
|
|
||||||
if len(c.Serves) > 0 {
|
|
||||||
return c.Serves
|
|
||||||
}
|
|
||||||
return m.Tools
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// At is this claim's scope, with the default applied.
|
// At is this claim's scope, with the default applied.
|
||||||
@@ -107,13 +86,8 @@ const (
|
|||||||
// If the path is absent when a machine applies, the host refuses clearly rather than creating it:
|
// If the path is absent when a machine applies, the host refuses clearly rather than creating it:
|
||||||
// the mesh does not own it, so conjuring it would be a lie the host then acts on.
|
// the mesh does not own it, so conjuring it would be a lie the host then acts on.
|
||||||
type Access struct {
|
type Access struct {
|
||||||
// ID is the name the module gives this access, which the assignment places
|
// Path is the absolute path on the machine, as the operator provides it.
|
||||||
// (`accesses: {<id>: <path>}`, novox/hq ADR 0112, issue 153) and the module's mounts name as
|
Path string `json:"path"`
|
||||||
// ${access:<id>}. The shape a definition should use: it names no path of any machine.
|
|
||||||
ID string `json:"id,omitempty"`
|
|
||||||
// Path is the absolute path on the machine. A definition carrying one names an installation;
|
|
||||||
// tolerated as the default the assignment may replace, for accesses declared before ids.
|
|
||||||
Path string `json:"path,omitempty"`
|
|
||||||
// Mode is "read" or "read-write". Absent narrows to read.
|
// Mode is "read" or "read-write". Absent narrows to read.
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -143,38 +117,6 @@ type Offer struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// Scope defaults to the node, which is where most things must be to be usable.
|
// Scope defaults to the node, which is where most things must be to be usable.
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
// Credential, when set, says this provision's credential is one of the provider's own secrets,
|
|
||||||
// shared by every consumer (novox/hq ADR 0158): software that holds one password or one key
|
|
||||||
// cannot give each consumer a login of its own. The named secret must say how it is taken.
|
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OfferCredential names which of the provider's own secrets a provision's consumers receive.
|
|
||||||
type OfferCredential struct {
|
|
||||||
Own string `json:"own"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SharedCredentialOf is the own secret an offer of this module names as the provision's credential,
|
|
||||||
// and whether it names one.
|
|
||||||
func (m Manifest) SharedCredentialOf(provision string) (string, bool) {
|
|
||||||
for _, o := range m.Provides {
|
|
||||||
if o.Name == provision && o.Credential != nil && o.Credential.Own != "" {
|
|
||||||
return o.Credential.Own, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProvisionsSharing is every provision of this module whose credential is the named own secret.
|
|
||||||
func (m Manifest) ProvisionsSharing(own string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, o := range m.Provides {
|
|
||||||
if o.Credential != nil && o.Credential.Own == own {
|
|
||||||
out = append(out, o.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// At is this offer's scope, with the default applied.
|
// At is this offer's scope, with the default applied.
|
||||||
@@ -193,30 +135,26 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var full struct {
|
var full struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
|
||||||
}
|
}
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
if err := json.Unmarshal(raw, &full); err != nil {
|
||||||
dec.DisallowUnknownFields()
|
return fmt.Errorf("a provided name is either a string or {name, scope}: %w", err)
|
||||||
if err := dec.Decode(&full); err != nil {
|
|
||||||
return fmt.Errorf("a provided name is either a string or {name, scope, credential}: %w", err)
|
|
||||||
}
|
}
|
||||||
o.Name, o.Scope, o.Credential = full.Name, full.Scope, full.Credential
|
o.Name, o.Scope = full.Name, full.Scope
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
||||||
// went through the mesh comes out looking like the one that went in.
|
// went through the mesh comes out looking like the one that went in.
|
||||||
func (o Offer) MarshalJSON() ([]byte, error) {
|
func (o Offer) MarshalJSON() ([]byte, error) {
|
||||||
if o.Scope == "" && o.Credential == nil {
|
if o.Scope == "" {
|
||||||
return json.Marshal(o.Name)
|
return json.Marshal(o.Name)
|
||||||
}
|
}
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope"`
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
}{o.Name, o.Scope})
|
||||||
}{o.Name, o.Scope, o.Credential})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest is everything a module says about itself.
|
// Manifest is everything a module says about itself.
|
||||||
@@ -309,22 +247,6 @@ type Manifest struct {
|
|||||||
// module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118).
|
// module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118).
|
||||||
Tools []string `json:"tools,omitempty"`
|
Tools []string `json:"tools,omitempty"`
|
||||||
|
|
||||||
// Instances says whether this module's instances are the same anywhere — `interchangeable` —
|
|
||||||
// so a call that names no machine may be answered by any of them (novox/hq ADR 0160). A fact
|
|
||||||
// about the software, not about the bus: a stateless web tool says it; a database does not,
|
|
||||||
// and its instances are then each addressed by machine, never confused for one another.
|
|
||||||
Instances string `json:"instances,omitempty"`
|
|
||||||
|
|
||||||
// Invokes are the tools this module calls, each `<module>.<tool>` or a role's `seat:<seat>.<verb>`,
|
|
||||||
// or the single entry `*` for every tool on the mesh (novox/hq ADR 0152, ADR 0154).
|
|
||||||
//
|
|
||||||
// **A grant, and only a grant.** The bus lets this module publish exactly those tool subjects
|
|
||||||
// and nothing beside them — no event, no subscription, no seat. A module that declares none
|
|
||||||
// calls nothing, which is every module but the console today. ADR 0095 made the control plane
|
|
||||||
// the one caller and deferred this until a consumer asked; the console is that consumer, and a
|
|
||||||
// person's account (design 25 §7) already had the same shape.
|
|
||||||
Invokes []string `json:"invokes,omitempty"`
|
|
||||||
|
|
||||||
// Capabilities the machine must have. A different field from Requires because the remedy
|
// Capabilities the machine must have. A different field from Requires because the remedy
|
||||||
// differs: a missing module can be assigned, and a missing capability means the wrong
|
// differs: a missing module can be assigned, and a missing capability means the wrong
|
||||||
// machine.
|
// machine.
|
||||||
@@ -448,16 +370,7 @@ type Manifest struct {
|
|||||||
// module running on three machines has three passwords and the mesh can read none of them. A
|
// module running on three machines has three passwords and the mesh can read none of them. A
|
||||||
// manifest carrying one instead would put the same secret on every machine that ever runs the
|
// manifest carrying one instead would put the same secret on every machine that ever runs the
|
||||||
// module, in a file anybody can read, for ever.
|
// module, in a file anybody can read, for ever.
|
||||||
//
|
OwnSecrets map[string]string `json:"own-secrets,omitempty"`
|
||||||
// **And how the module takes it** (novox/hq ADR 0114, issue 180): `"admin": "<path>"` says
|
|
||||||
// where and nothing else; `"admin": {"path": "<path>", "taken": "at-start"}` says the module
|
|
||||||
// reads the file when it starts, so the mesh may rotate it by making a new value and starting
|
|
||||||
// the module again; `"taken": "applied"` says the module's own code applies it to a backend
|
|
||||||
// that takes it only once, so a rotation must be staged beside the current value — the form the
|
|
||||||
// mesh does not build yet, and refuses by name. A secret that says neither is not rotated by
|
|
||||||
// the mesh: the one fault worse than an unrotated credential is a rotated one the software
|
|
||||||
// never saw.
|
|
||||||
OwnSecrets OwnSecrets `json:"own-secrets,omitempty"`
|
|
||||||
|
|
||||||
// SecretsOwner is who the files holding this module's secrets belong to on the machine —
|
// SecretsOwner is who the files holding this module's secrets belong to on the machine —
|
||||||
// `uid:gid`, or a name — when its process is not root.
|
// `uid:gid`, or a name — when its process is not root.
|
||||||
@@ -621,14 +534,8 @@ type BuildsOn struct {
|
|||||||
type ArtifactContext struct {
|
type ArtifactContext struct {
|
||||||
// Repository is cloned fresh, the same way the module's own repository is — a working tree
|
// Repository is cloned fresh, the same way the module's own repository is — a working tree
|
||||||
// nothing has touched, so what was built is reproducible from the two commits named rather
|
// nothing has touched, so what was built is reproducible from the two commits named rather
|
||||||
// than from whatever a previous build happened to leave behind. A URL, or — with Seat — a
|
// than from whatever a previous build happened to leave behind.
|
||||||
// path on that seat's holder, `<owner>/<name>`.
|
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
// Seat is the seat the repository lives on: `git` for this mesh's own forge (novox/hq ADR 0111,
|
|
||||||
// ADR 0155). A context written as a URL names one installation's forge and can be built
|
|
||||||
// nowhere else; a path on the seat is composed by the mesh that builds it, whichever forge
|
|
||||||
// holds the seat there.
|
|
||||||
Seat string `json:"seat,omitempty"`
|
|
||||||
// Ref is the branch, tag or commit of that repository to build. Empty means its own default
|
// Ref is the branch, tag or commit of that repository to build. Empty means its own default
|
||||||
// branch — the same meaning an empty module ref already has.
|
// branch — the same meaning an empty module ref already has.
|
||||||
Ref string `json:"ref,omitempty"`
|
Ref string `json:"ref,omitempty"`
|
||||||
@@ -690,16 +597,6 @@ type Artifact struct {
|
|||||||
// Empty for every other kind, which do not compile.
|
// Empty for every other kind, which do not compile.
|
||||||
Language string `json:"language,omitempty"`
|
Language string `json:"language,omitempty"`
|
||||||
|
|
||||||
// Binary is what the compiled executable is called, for a bundle in a language that compiles to
|
|
||||||
// one. Empty means the package's own name, which is what a compiler does by default.
|
|
||||||
//
|
|
||||||
// **Because the name a machine runs it by is not always the name of the package that built it.**
|
|
||||||
// The host's command is `cmd/mesh-host` and every machine runs it as `nox-mesh-host` — the path
|
|
||||||
// it is installed at, the name in its unit, and the name its launcher looks for inside a
|
|
||||||
// delivered version. A bundle that carried the package's name was delivered correctly, reported
|
|
||||||
// success, and was invisible to the launcher (novox/hq 04-ISSUES/142).
|
|
||||||
Binary string `json:"binary,omitempty"`
|
|
||||||
|
|
||||||
// Entrypoints are the compiled files a tool host should load from this module, relative to the
|
// Entrypoints are the compiled files a tool host should load from this module, relative to the
|
||||||
// bundle's root.
|
// bundle's root.
|
||||||
//
|
//
|
||||||
@@ -1195,33 +1092,11 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
m.Module, r))
|
m.Module, r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.Instances != "" && m.Instances != InstancesInterchangeable {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says its instances are %q; the one word is %q, for a module that is the same on every machine",
|
|
||||||
m.Module, m.Instances, InstancesInterchangeable))
|
|
||||||
}
|
|
||||||
for _, offer := range m.Provides {
|
for _, offer := range m.Provides {
|
||||||
p := offer.Name
|
p := offer.Name
|
||||||
if !name.MatchString(p) {
|
if !name.MatchString(p) {
|
||||||
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
|
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
|
||||||
}
|
}
|
||||||
if offer.Credential != nil {
|
|
||||||
own, declared := m.OwnSecrets[offer.Credential.Own]
|
|
||||||
switch {
|
|
||||||
case offer.Credential.Own == "":
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s provides %q with a credential that names no own secret", m.Module, p))
|
|
||||||
case !declared:
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s provides %q with its own secret %q as the credential, and declares no such secret",
|
|
||||||
m.Module, p, offer.Credential.Own))
|
|
||||||
case own.Taken == "":
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s provides %q with its own secret %q as the credential every consumer receives, so "+
|
|
||||||
"the secret must say how the module takes it: \"taken\": \"at-start\" or \"applied\" (ADR 0158)",
|
|
||||||
m.Module, p, offer.Credential.Own))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if instead, generic := engineGeneric[p]; generic {
|
if instead, generic := engineGeneric[p]; generic {
|
||||||
// A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing
|
// A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing
|
||||||
// the role means a requirement for it matches any engine, resolves as satisfied, and
|
// the role means a requirement for it matches any engine, resolves as satisfied, and
|
||||||
@@ -1405,7 +1280,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p))
|
"%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
problems = append(problems, invokeProblems(m)...)
|
|
||||||
problems = append(problems, endpointNameProblems(m)...)
|
problems = append(problems, endpointNameProblems(m)...)
|
||||||
problems = append(problems, RouteProblems(m)...)
|
problems = append(problems, RouteProblems(m)...)
|
||||||
for _, port := range m.Guards {
|
for _, port := range m.Guards {
|
||||||
@@ -1508,20 +1382,14 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for name, own := range m.OwnSecrets {
|
for name, where := range m.OwnSecrets {
|
||||||
if !placedOrAbsolute(own.Path) {
|
if !placedOrAbsolute(where) {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, own.Path))
|
"%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, where))
|
||||||
}
|
}
|
||||||
if name == "" {
|
if name == "" {
|
||||||
problems = append(problems, m.Module+" needs a secret with no name")
|
problems = append(problems, m.Module+" needs a secret with no name")
|
||||||
}
|
}
|
||||||
if own.Taken != "" && own.Taken != TakenAtStart && own.Taken != TakenApplied {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says its secret %q is taken %q; a secret is taken %q (read when the module starts) "+
|
|
||||||
"or %q (applied by the module's own code to a backend that takes it once)",
|
|
||||||
m.Module, name, own.Taken, TakenAtStart, TakenApplied))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
localOf := map[string]string{}
|
localOf := map[string]string{}
|
||||||
for _, to := range m.SecretRequirements() {
|
for _, to := range m.SecretRequirements() {
|
||||||
@@ -1625,16 +1493,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, a := range m.Accesses {
|
for _, a := range m.Accesses {
|
||||||
if a.ID == "" && a.Path == "" {
|
if !strings.HasPrefix(a.Path, "/") {
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares an access with neither an id nor a path — an id, which the assignment places",
|
|
||||||
m.Module))
|
|
||||||
}
|
|
||||||
if a.ID != "" && !accessRef.MatchString("${access:"+a.ID+"}") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s accesses %q; an access id is lowercase letters, digits and dashes", m.Module, a.ID))
|
|
||||||
}
|
|
||||||
if a.Path != "" && !strings.HasPrefix(a.Path, "/") {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s accesses %q, which is not an absolute path", m.Module, a.Path))
|
"%s accesses %q, which is not an absolute path", m.Module, a.Path))
|
||||||
}
|
}
|
||||||
@@ -1666,7 +1525,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
// the time it sees the mount it is being asked to create the directory, which it can do.
|
// the time it sees the mount it is being asked to create the directory, which it can do.
|
||||||
problems = append(problems, m.undeclaredMounts()...)
|
problems = append(problems, m.undeclaredMounts()...)
|
||||||
problems = append(problems, m.unknownDirRefs()...)
|
problems = append(problems, m.unknownDirRefs()...)
|
||||||
problems = append(problems, m.unknownAccessRefs()...)
|
|
||||||
|
|
||||||
for i, r := range m.Resources {
|
for i, r := range m.Resources {
|
||||||
id, _ := r["id"].(string)
|
id, _ := r["id"].(string)
|
||||||
@@ -1785,8 +1643,8 @@ func (m Manifest) undeclaredMounts() []string {
|
|||||||
claim(fmt.Sprint(r["path"]))
|
claim(fmt.Sprint(r["path"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, own := range m.OwnSecrets {
|
for _, where := range m.OwnSecrets {
|
||||||
claim(own.Path)
|
claim(where)
|
||||||
}
|
}
|
||||||
for _, to := range m.SecretRequirements() {
|
for _, to := range m.SecretRequirements() {
|
||||||
for _, f := range m.SecretFiles(to) {
|
for _, f := range m.SecretFiles(to) {
|
||||||
@@ -1898,96 +1756,3 @@ func EndpointPort(m Manifest, name string) (int, bool) {
|
|||||||
}
|
}
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// invokeProblems judges what a module says it calls (novox/hq ADR 0152).
|
|
||||||
//
|
|
||||||
// Refused here, in the manifest's words, rather than at the next composition of the bus's user
|
|
||||||
// list — where a bad entry would stop the whole file being written for everybody, as a person's
|
|
||||||
// malformed grant would have (operator.go). An entry that names a module and no tool is the one
|
|
||||||
// mistake worth naming: `shop` reads like a grant to a module's tools and would be a grant to nothing.
|
|
||||||
func invokeProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, t := range m.Invokes {
|
|
||||||
if t == "*" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
t = strings.TrimPrefix(t, "seat:")
|
|
||||||
module, tool, named := strings.Cut(t, ".")
|
|
||||||
if !named || !name.MatchString(module) || !toolName.MatchString(tool) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s invokes %q, which does not name a tool: a module invokes <module>.<tool>, or "+
|
|
||||||
"* for every tool on the mesh (novox/hq ADR 0152)", m.Module, t))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// How a module takes one of its own secrets (ADR 0114): read from the file when it starts, or
|
|
||||||
// applied by its own code to a backend that takes it once.
|
|
||||||
const (
|
|
||||||
TakenAtStart = "at-start"
|
|
||||||
TakenApplied = "applied"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OwnSecret is where one of a module's own secrets lands, and how the module takes it.
|
|
||||||
type OwnSecret struct {
|
|
||||||
Path string
|
|
||||||
Taken string
|
|
||||||
}
|
|
||||||
|
|
||||||
// OwnSecrets is a module's own secrets by name. On the wire each is a path, or an object naming
|
|
||||||
// the path and how it is taken; written back the way it was read, so a manifest the mesh holds
|
|
||||||
// keeps its bytes.
|
|
||||||
type OwnSecrets map[string]OwnSecret
|
|
||||||
|
|
||||||
func (o *OwnSecrets) UnmarshalJSON(raw []byte) error {
|
|
||||||
var entries map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(raw, &entries); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
out := make(OwnSecrets, len(entries))
|
|
||||||
for name, body := range entries {
|
|
||||||
var path string
|
|
||||||
if err := json.Unmarshal(body, &path); err == nil {
|
|
||||||
out[name] = OwnSecret{Path: path}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var long struct {
|
|
||||||
Path string `json:"path"`
|
|
||||||
Taken string `json:"taken,omitempty"`
|
|
||||||
}
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(body))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&long); err != nil {
|
|
||||||
return fmt.Errorf("own-secrets.%s: a path, or {\"path\", \"taken\"}: %w", name, err)
|
|
||||||
}
|
|
||||||
out[name] = OwnSecret{Path: long.Path, Taken: long.Taken}
|
|
||||||
}
|
|
||||||
*o = out
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o OwnSecrets) MarshalJSON() ([]byte, error) {
|
|
||||||
entries := make(map[string]any, len(o))
|
|
||||||
for name, s := range o {
|
|
||||||
if s.Taken == "" {
|
|
||||||
entries[name] = s.Path
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries[name] = map[string]string{"path": s.Path, "taken": s.Taken}
|
|
||||||
}
|
|
||||||
return json.Marshal(entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paths is each own secret's path by name — the shape every placement and file walk reads.
|
|
||||||
func (o OwnSecrets) Paths() map[string]string {
|
|
||||||
out := make(map[string]string, len(o))
|
|
||||||
for name, s := range o {
|
|
||||||
out[name] = s.Path
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstancesInterchangeable is the one value of a definition's `instances`: the module is the same
|
|
||||||
// on every machine, so any instance may answer for the module.
|
|
||||||
const InstancesInterchangeable = "interchangeable"
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -31,38 +30,36 @@ func namesOf(r map[string]any) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// No mesh name is written into a container (novox/hq ADR 0148). It resolves them through its
|
// A container does not inherit the machine's names, so the mesh gives them to it.
|
||||||
// machine's resolver at the moment it asks, so a name that moves is answered differently by the
|
|
||||||
// next lookup, in every container, with nothing recreated.
|
|
||||||
//
|
//
|
||||||
// Checked the way the record says: the declaration a container gets does not move when the mesh's
|
// It gets its own hosts file holding only its own hostname — every internal name the mesh wrote
|
||||||
// roster does. A roster with one machine and a roster with three produce the same container, byte
|
// for the machine is invisible to what the machine runs. A database client on one node could not
|
||||||
// for byte, so the digest a host computes from it cannot move either — which is what stopped one
|
// resolve another node, on a mesh where both names were correct and present on both machines.
|
||||||
// name moving from replacing every container in the mesh (issue 151).
|
func TestEveryContainerIsGivenTheMeshsNames(t *testing.T) {
|
||||||
func TestAContainerIsTheSameWhateverTheMeshsRosterSays(t *testing.T) {
|
got := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{{
|
||||||
module := Manifest{Module: "app", Resources: []map[string]any{{"id": "web", "type": "container",
|
Module: "app",
|
||||||
"name": "web", "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}}
|
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
||||||
one := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{module}},
|
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}},
|
||||||
Rendering{Names: map[string]string{"laptop.internal": "10.42.0.2"}})
|
}}}, Rendering{Names: map[string]string{
|
||||||
three := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{module}},
|
"anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2",
|
||||||
Rendering{Names: map[string]string{
|
}})
|
||||||
"anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2", "git.example.tld": "10.42.0.1",
|
if len(got) != 1 {
|
||||||
}})
|
t.Fatalf("expected one container, got %d", len(got))
|
||||||
if len(one) != 1 || len(three) != 1 {
|
|
||||||
t.Fatalf("expected one container each, got %d and %d", len(one), len(three))
|
|
||||||
}
|
}
|
||||||
if given := namesOf(three[0]); len(given) != 0 {
|
given := namesOf(got[0])
|
||||||
t.Fatalf("the mesh's names were copied into the container: %v", given)
|
if len(given) != 2 {
|
||||||
|
t.Fatalf("the container was given %d name(s): %v", len(given), given)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(one[0], three[0]) {
|
if given[0] != "anchor.internal:10.42.0.1" {
|
||||||
t.Fatalf("the container moved with the roster:\n%v\n%v", one[0], three[0])
|
t.Fatalf("the name is not in the form a runtime writes: %v", given)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The names a module declares for itself are its own: part of what the module is, kept exactly as
|
// A container that named its own keeps them and gets the mesh's beside them.
|
||||||
// written, and the mesh does not know what they mean. They are the one thing in a container's
|
//
|
||||||
// hosts that does move its identity, because they do not move when the mesh's roster does.
|
// The mesh does not know what else a workload needs to reach, and taking something away in order
|
||||||
func TestAContainersOwnNamesAreKeptAsWritten(t *testing.T) {
|
// to add something is not what "also" means.
|
||||||
|
func TestAContainersOwnNamesAreKept(t *testing.T) {
|
||||||
got := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{{
|
got := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{{
|
||||||
Module: "app",
|
Module: "app",
|
||||||
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
||||||
@@ -71,15 +68,62 @@ func TestAContainersOwnNamesAreKeptAsWritten(t *testing.T) {
|
|||||||
}}}, Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}})
|
}}}, Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}})
|
||||||
|
|
||||||
given := namesOf(got[0])
|
given := namesOf(got[0])
|
||||||
if len(given) != 1 || given[0] != "something.else:203.0.113.9" {
|
if len(given) != 2 || given[0] != "something.else:203.0.113.9" {
|
||||||
t.Fatalf("the container's own names were not kept as written: %v", given)
|
t.Fatalf("the container's own names were lost: %v", given)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No resource is given a `hosts` key it did not declare. A file or a service carrying one is a
|
// A container on the machine's own network gets the names too — it does NOT share the machine's
|
||||||
// declaration the host refuses outright — it takes no unknown field — so an invented key breaks
|
// hosts file. `docker run --network host` still gives the container its own /etc/hosts (localhost
|
||||||
// the whole machine rather than one resource.
|
// and its own id only), so every `<node>.internal` name the mesh wrote is invisible inside it, and a
|
||||||
func TestNothingIsGivenNamesItDidNotDeclare(t *testing.T) {
|
// client that dials one gets EAI_AGAIN. It gets the same `--add-host` entries every other container
|
||||||
|
// gets (the runtime accepts them with `--network host`), so a host-network consumer can reach a
|
||||||
|
// provider by the `.internal` address the mesh hands it.
|
||||||
|
func TestAContainerOnTheMachinesNetworkIsGivenTheNamesToo(t *testing.T) {
|
||||||
|
got := containersOf(t, Resolution{Node: "anchor", Modules: []Manifest{{
|
||||||
|
Module: "control",
|
||||||
|
Resources: []map[string]any{{"id": "c", "type": "container", "name": "c",
|
||||||
|
"image": "registry.example/c@sha256:" + strings.Repeat("a", 64), "network": "host"}},
|
||||||
|
}}}, Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}})
|
||||||
|
|
||||||
|
given := namesOf(got[0])
|
||||||
|
if len(given) != 1 || given[0] != "anchor.internal:10.42.0.1" {
|
||||||
|
t.Fatalf("a host-networked container was not given the mesh's names: %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mesh with no private network gives nothing, rather than a name with no address behind it.
|
||||||
|
func TestAMeshWithNoNamesGivesNone(t *testing.T) {
|
||||||
|
got := containersOf(t, Resolution{Node: "alone", Modules: []Manifest{{
|
||||||
|
Module: "app",
|
||||||
|
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
||||||
|
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}},
|
||||||
|
}}}, Rendering{})
|
||||||
|
if len(namesOf(got[0])) != 0 {
|
||||||
|
t.Fatalf("names were invented for a mesh that has none: %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalogue's copy is not edited: these maps come from a manifest, and mutating one would
|
||||||
|
// change what every other machine running that module is given.
|
||||||
|
func TestGivingNamesDoesNotChangeTheCatalogue(t *testing.T) {
|
||||||
|
held := map[string]any{"id": "web", "type": "container", "name": "web",
|
||||||
|
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}
|
||||||
|
module := Manifest{Module: "app", Resources: []map[string]any{held}}
|
||||||
|
|
||||||
|
for _, node := range []string{"one", "two"} {
|
||||||
|
containersOf(t, Resolution{Node: node, Modules: []Manifest{module}},
|
||||||
|
Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}})
|
||||||
|
}
|
||||||
|
if _, changed := held["hosts"]; changed {
|
||||||
|
t.Fatal("the manifest the catalogue holds was edited, so every machine now carries this")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only containers. A file or a service given a `hosts` key is a declaration the host refuses
|
||||||
|
// outright — it takes no unknown field — so getting this wrong breaks the whole machine rather
|
||||||
|
// than one resource, and breaks it for something that was never about names.
|
||||||
|
func TestNothingButAContainerIsGivenNames(t *testing.T) {
|
||||||
out, err := Resolution{Node: "laptop", Modules: []Manifest{{
|
out, err := Resolution{Node: "laptop", Modules: []Manifest{{
|
||||||
Module: "app",
|
Module: "app",
|
||||||
Resources: []map[string]any{
|
Resources: []map[string]any{
|
||||||
@@ -93,8 +137,11 @@ func TestNothingIsGivenNamesItDidNotDeclare(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, r := range out {
|
for _, r := range out {
|
||||||
|
if r["type"] == "container" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, given := r["hosts"]; given {
|
if _, given := r["hosts"]; given {
|
||||||
t.Fatalf("a %v was given names it never declared: %v", r["type"], r)
|
t.Fatalf("a %v was given names, which the host will refuse: %v", r["type"], r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
func needy() Manifest {
|
func needy() Manifest {
|
||||||
return Manifest{
|
return Manifest{
|
||||||
Module: "postgres", Version: "1",
|
Module: "postgres", Version: "1",
|
||||||
OwnSecrets: OwnSecrets{"superuser": {Path: "/var/lib/mesh/postgres/superuser"}},
|
OwnSecrets: map[string]string{"superuser": "/var/lib/mesh/postgres/superuser"},
|
||||||
Resources: []map[string]any{
|
Resources: []map[string]any{
|
||||||
{"id": "store", "type": "container", "name": "mesh-postgres", "image": "postgres@sha256:x"},
|
{"id": "store", "type": "container", "name": "mesh-postgres", "image": "postgres@sha256:x"},
|
||||||
},
|
},
|
||||||
@@ -74,7 +74,7 @@ func TestANeedIsAnAbsolutePath(t *testing.T) {
|
|||||||
func TestAModuleMayNeedSeveralThings(t *testing.T) {
|
func TestAModuleMayNeedSeveralThings(t *testing.T) {
|
||||||
// A password and a token, say. Telling them apart is the module's business, not the mesh's.
|
// A password and a token, say. Telling them apart is the module's business, not the mesh's.
|
||||||
m := needy()
|
m := needy()
|
||||||
m.OwnSecrets["replication"] = OwnSecret{Path: "/var/lib/mesh/postgres/replication"}
|
m.OwnSecrets["replication"] = "/var/lib/mesh/postgres/replication"
|
||||||
got, _ := Resolve(shelf(m), []string{"postgres"}, reachable(), World{})
|
got, _ := Resolve(shelf(m), []string{"postgres"}, reachable(), World{})
|
||||||
out, err := got.Declaration(Rendering{Needed: map[string]map[string]string{
|
out, err := got.Declaration(Rendering{Needed: map[string]map[string]string{
|
||||||
"postgres": {"superuser": "b25l", "replication": "dHdv"},
|
"postgres": {"superuser": "b25l", "replication": "dHdv"},
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// An own secret says how the module takes it (novox/hq ADR 0114, issue 180): a path alone says
|
|
||||||
// nothing of it, an object says `at-start` or `applied`, and the bytes the mesh holds are the bytes
|
|
||||||
// it was given either way.
|
|
||||||
func TestAnOwnSecretSaysHowItIsTaken(t *testing.T) {
|
|
||||||
m, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{
|
|
||||||
"broker":"/var/lib/mesh/idp/broker",
|
|
||||||
"admin":{"path":"/var/lib/idp/admin.secret","taken":"applied"},
|
|
||||||
"session":{"path":"/var/lib/idp/session.secret","taken":"at-start"}}}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if m.OwnSecrets["broker"] != (OwnSecret{Path: "/var/lib/mesh/idp/broker"}) {
|
|
||||||
t.Fatalf("a path alone is a path and nothing more: %+v", m.OwnSecrets["broker"])
|
|
||||||
}
|
|
||||||
if m.OwnSecrets["admin"].Taken != TakenApplied || m.OwnSecrets["session"].Taken != TakenAtStart {
|
|
||||||
t.Fatalf("the word was not kept: %+v", m.OwnSecrets)
|
|
||||||
}
|
|
||||||
// Written back the way it was read, so a registered manifest keeps its bytes.
|
|
||||||
out, err := json.Marshal(m.OwnSecrets)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var again OwnSecrets
|
|
||||||
if err := json.Unmarshal(out, &again); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(again) != 3 || again["admin"].Taken != TakenApplied || again["broker"].Taken != "" {
|
|
||||||
t.Fatalf("the round trip changed the secrets: %s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(out), `"broker":"/var/lib/mesh/idp/broker"`) {
|
|
||||||
t.Fatalf("a path alone is written back as a path: %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnOwnSecretTakenSomeOtherWayIsRefused(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{
|
|
||||||
"admin":{"path":"/var/lib/idp/admin.secret","taken":"sometimes"}}}`))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), `taken "sometimes"`) {
|
|
||||||
t.Fatalf("an unknown word for how a secret is taken was accepted: %v", err)
|
|
||||||
}
|
|
||||||
_, err = ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{
|
|
||||||
"admin":{"path":"/var/lib/idp/admin.secret","rotate":"yes"}}}`))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("an unknown field on an own secret was accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Two modules on one node both provide acme-ca — public-acme (Let's Encrypt) and step-ca (the
|
|
||||||
// mesh's own authority) on novox — and a route-proxy elsewhere must get the public one (novox/hq
|
|
||||||
// #258). A pin names the module as well as the node, so that it can say which.
|
|
||||||
|
|
||||||
func issuerShelf() map[string]Manifest {
|
|
||||||
return shelf(
|
|
||||||
Manifest{Module: "public-acme", Version: "1", Provides: FromAnywhere("acme-ca"),
|
|
||||||
Serves: map[string]map[string]any{"acme-ca": {"at": "acme-v02.api.letsencrypt.org"}}},
|
|
||||||
Manifest{Module: "step-ca", Version: "1", Provides: FromAnywhere("acme-ca"),
|
|
||||||
Serves: map[string]map[string]any{"acme-ca": {"at": "novox.internal"}}},
|
|
||||||
Manifest{Module: "route-proxy", Version: "1", Requires: []string{"acme-ca"}},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func twoIssuersOnOneNode() map[string][]Provider {
|
|
||||||
return map[string][]Provider{"acme-ca": {
|
|
||||||
{Node: "novox", At: "novox.internal", Module: "public-acme", Serves: map[string]any{"at": "acme-v02.api.letsencrypt.org"}},
|
|
||||||
{Node: "novox", At: "novox.internal", Module: "step-ca", Serves: map[string]any{"at": "novox.internal"}},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTwoProvidersOnOneNodeAreRefusedWithBothNamed(t *testing.T) {
|
|
||||||
// The refusal must name the pair, because a node alone cannot tell them apart.
|
|
||||||
_, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(),
|
|
||||||
World{Offered: twoIssuersOnOneNode()})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("two providers on one node were resolved by picking")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"novox/public-acme", "novox/step-ca", "<node> <module>"} {
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Fatalf("the refusal does not say %q: %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPinNamesTheModule(t *testing.T) {
|
|
||||||
got, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(),
|
|
||||||
World{Offered: twoIssuersOnOneNode(),
|
|
||||||
Pinned: map[string]Chosen{"acme-ca": {Node: "novox", Module: "public-acme"}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got.Needs) != 1 || got.Needs[0].From != "novox" || got.Needs[0].Serves["at"] != "acme-v02.api.letsencrypt.org" {
|
|
||||||
t.Fatalf("the named module was not the one taken: %+v", got.Needs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestARecordNamingOnlyTheNodeIsRefusedWhenThatNodeAnswersTwice(t *testing.T) {
|
|
||||||
// A pin from before the module was asked for. It once took the last one listed — a coin flip.
|
|
||||||
_, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(),
|
|
||||||
World{Offered: twoIssuersOnOneNode(), Pinned: map[string]Chosen{"acme-ca": {Node: "novox"}}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a node that answers twice was resolved by picking")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "provides it 2 times") || !strings.Contains(err.Error(), "pin workstation acme-ca novox <module>") {
|
|
||||||
t.Fatalf("the refusal does not ask for the module: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPinNamingAModuleThatDoesNotProvideItIsRefused(t *testing.T) {
|
|
||||||
_, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(),
|
|
||||||
World{Offered: twoIssuersOnOneNode(), Pinned: map[string]Chosen{"acme-ca": {Node: "novox", Module: "gitea"}}})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "novox/gitea does not provide it") {
|
|
||||||
t.Fatalf("a module that does not provide it was not refused by name: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTwoProvidersBesideTheConsumerAreRefusedUntilOneIsNamed(t *testing.T) {
|
|
||||||
// The same ambiguity on the consumer's own machine. This was settled by a map walk — random,
|
|
||||||
// per plan — which is how novox's own proxy got its issuer.
|
|
||||||
_, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(), World{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("two providers beside the consumer were resolved by picking")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "workstation provides \"acme-ca\" 2 times") || !strings.Contains(err.Error(), "public-acme, step-ca") {
|
|
||||||
t.Fatalf("the refusal does not list them: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPinSettlesTwoProvidersBesideTheConsumer(t *testing.T) {
|
|
||||||
got, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(),
|
|
||||||
World{Pinned: map[string]Chosen{"acme-ca": {Node: "workstation", Module: "public-acme"}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var found bool
|
|
||||||
for _, n := range got.Needs {
|
|
||||||
if n.Name == "acme-ca" {
|
|
||||||
found = true
|
|
||||||
if n.Serves["at"] != "acme-v02.api.letsencrypt.org" {
|
|
||||||
t.Fatalf("the named module was not the one taken: %+v", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatalf("no need for acme-ca was created: %+v", got.Needs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheFirstPassDoesNotRefuseTwoProvidersBesideTheConsumer(t *testing.T) {
|
|
||||||
// The first pass answers only what a node offers. Refused there, the node vanishes from every
|
|
||||||
// other node's world — and the whole mesh loses its vault for an ambiguity one machine has to
|
|
||||||
// settle. The second pass is where it is refused, and the test above proves it is.
|
|
||||||
if _, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(),
|
|
||||||
World{Unchecked: true}); err != nil {
|
|
||||||
t.Fatalf("the first pass refused what only the second may: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestPlacedDirectoriesKeepTheirPaths is the check novox/hq issue 119 asks for before a definition
|
|
||||||
// stops naming where its data lives: a converted manifest, resolved on a node with the default root,
|
|
||||||
// names exactly the paths the manifest before it named. Data that a service is using must not move
|
|
||||||
// because a definition stopped saying where it was.
|
|
||||||
//
|
|
||||||
// Two checkouts: MESH_CATALOGUE_BEFORE, the catalogue as it was, and MESH_CATALOGUE, as it is now.
|
|
||||||
// Every module in both is resolved with the controller's own rule (dirsFor, dirFill) and compared
|
|
||||||
// whole — not only the directories, but every string a directory's id was written into. Both
|
|
||||||
// sides are resolved, so a manifest converted in two steps (issue 119, then issue 174) is judged
|
|
||||||
// against the paths it named, not the text it used to name them with.
|
|
||||||
func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) {
|
|
||||||
before, after := os.Getenv("MESH_CATALOGUE_BEFORE"), os.Getenv("MESH_CATALOGUE")
|
|
||||||
if before == "" || after == "" {
|
|
||||||
t.Skip("set MESH_CATALOGUE_BEFORE and MESH_CATALOGUE to two catalogue checkouts to run this")
|
|
||||||
}
|
|
||||||
found, _ := filepath.Glob(filepath.Join(after, "modules", "*", "module.json"))
|
|
||||||
compared := 0
|
|
||||||
for _, path := range found {
|
|
||||||
module := filepath.Base(filepath.Dir(path))
|
|
||||||
old, err := os.ReadFile(filepath.Join(before, "modules", module, "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
continue // new since; nothing to keep
|
|
||||||
}
|
|
||||||
now, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(old) == string(now) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m, err := ParseManifest(now)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s: %v", module, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
earlier, err := ParseManifest(old)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("%s before: %v", module, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var was, is any
|
|
||||||
if err := json.Unmarshal(old, &was); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(now, &is); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Both sides resolved: the manifest before may itself already place some directories
|
|
||||||
// (issue 119's conversion), and what must not move is the path a machine sees.
|
|
||||||
was = resolvedTree(was, dirsFor(earlier, Rendering{}), module, t)
|
|
||||||
resolved := resolvedTree(is, dirsFor(m, Rendering{}), module, t)
|
|
||||||
// An access named by id resolves to the path the definition still carries as its default
|
|
||||||
// (issue 153) — the same rule as a placed directory: an assignment that says nothing moves
|
|
||||||
// nothing.
|
|
||||||
was = accessesResolved(was, earlier)
|
|
||||||
resolved = accessesResolved(resolved, m)
|
|
||||||
if !reflect.DeepEqual(was, resolved) {
|
|
||||||
wasJSON, _ := json.MarshalIndent(was, "", " ")
|
|
||||||
isJSON, _ := json.MarshalIndent(resolved, "", " ")
|
|
||||||
t.Errorf("%s: resolved on the default root, the converted manifest is not the one before it\n--- before\n%s\n--- resolved now\n%s",
|
|
||||||
module, firstDifference(string(wasJSON), string(isJSON)), "")
|
|
||||||
}
|
|
||||||
compared++
|
|
||||||
}
|
|
||||||
t.Logf("%d converted manifest(s) resolve to the paths they named before", compared)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolvedTree is the manifest as a machine would see it: every ${dir:…} filled, a pathless
|
|
||||||
// directory given the path it resolves to, and the placement word removed.
|
|
||||||
func resolvedTree(node any, dirs map[string]string, module string, t *testing.T) any {
|
|
||||||
switch v := node.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
out := map[string]any{}
|
|
||||||
for k, child := range v {
|
|
||||||
if k == "place" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[k] = resolvedTree(child, dirs, module, t)
|
|
||||||
}
|
|
||||||
if out["type"] == "directory" {
|
|
||||||
if _, has := out["path"]; !has {
|
|
||||||
if id, ok := out["id"].(string); ok {
|
|
||||||
out["path"] = dirs[id]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []any:
|
|
||||||
out := make([]any, len(v))
|
|
||||||
for i, child := range v {
|
|
||||||
out[i] = resolvedTree(child, dirs, module, t)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case string:
|
|
||||||
filled, err := dirFill(v, dirs, module)
|
|
||||||
if err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
}
|
|
||||||
return filled
|
|
||||||
}
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstDifference(a, b string) string {
|
|
||||||
al, bl := strings.Split(a, "\n"), strings.Split(b, "\n")
|
|
||||||
for i := range al {
|
|
||||||
if i >= len(bl) || al[i] != bl[i] {
|
|
||||||
from := i - 2
|
|
||||||
if from < 0 {
|
|
||||||
from = 0
|
|
||||||
}
|
|
||||||
to := i + 3
|
|
||||||
if to > len(al) {
|
|
||||||
to = len(al)
|
|
||||||
}
|
|
||||||
bt := i + 3
|
|
||||||
if bt > len(bl) {
|
|
||||||
bt = len(bl)
|
|
||||||
}
|
|
||||||
return "before:\n" + strings.Join(al[from:to], "\n") + "\nnow:\n" + strings.Join(bl[from:bt], "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "(the difference is beyond the shorter document)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// accessesResolved fills every ${access:<id>} with the default path the definition carries for that
|
|
||||||
// access, and drops the id, so a manifest that names its accesses is compared by the paths a machine
|
|
||||||
// with no placement receives.
|
|
||||||
func accessesResolved(node any, m Manifest) any {
|
|
||||||
defaults := map[string]string{}
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
if a.ID != "" && a.Path != "" {
|
|
||||||
defaults[a.ID] = a.Path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var walk func(any) any
|
|
||||||
walk = func(n any) any {
|
|
||||||
switch v := n.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
out := map[string]any{}
|
|
||||||
for k, child := range v {
|
|
||||||
if k == "id" {
|
|
||||||
if _, isAccess := v["mode"]; isAccess && v["type"] == nil {
|
|
||||||
if _, hasPath := v["path"]; hasPath {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out[k] = walk(child)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case []any:
|
|
||||||
out := make([]any, len(v))
|
|
||||||
for i, child := range v {
|
|
||||||
out[i] = walk(child)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
case string:
|
|
||||||
return accessRef.ReplaceAllStringFunc(v, func(ref string) string {
|
|
||||||
if p, ok := defaults[accessRef.FindStringSubmatch(ref)[1]]; ok {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
return ref
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
return walk(node)
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Where a module's data is on THIS machine is the assignment's (novox/hq ADR 0112, issue 153).
|
|
||||||
//
|
|
||||||
// A definition names no host path. It declares the directories it owns by id, and the operator's
|
|
||||||
// shared data it needs by id too (an `access`, ADR 0051). A node has a default layout for the
|
|
||||||
// former — <root>/<module>/<id> — and nothing at all for the latter, because shared data is
|
|
||||||
// wherever the operator keeps it. An adopted machine keeps its data where the predecessor put it:
|
|
||||||
// a 40 TB library on its own pool, a configuration on a second disk. Both halves are said on the
|
|
||||||
// assignment, validated the way `endpoints` is — an id the module does not declare is refused,
|
|
||||||
// because a setting that reaches nothing is a mistake — and resolved here, so the host receives
|
|
||||||
// concrete paths exactly as it always has and learns no field.
|
|
||||||
//
|
|
||||||
// {"places": {"config": "/services/sonarr/config",
|
|
||||||
// "data": {"path": "/mnt/plex/data", "owner": "1000:1000"}},
|
|
||||||
// "accesses": {"series": "/storage/media/series",
|
|
||||||
// "downloads": "/storage/downloads"}}
|
|
||||||
//
|
|
||||||
// A placed directory is still the mesh's: created, chowned to the owner the assignment says (or
|
|
||||||
// the manifest's), removed when empty and no longer declared. A placed access is still the
|
|
||||||
// operator's: mounted, never created, chowned or removed.
|
|
||||||
|
|
||||||
// PlacesSetting is the settings key that places a module's declared directories, by id.
|
|
||||||
const PlacesSetting = "places"
|
|
||||||
|
|
||||||
// AccessesSetting is the settings key that says where a module's accesses are on this node, by id.
|
|
||||||
const AccessesSetting = "accesses"
|
|
||||||
|
|
||||||
// Placement is what an assignment says about one of a module's directories.
|
|
||||||
type Placement struct {
|
|
||||||
// Path is where the directory is on this machine. Absolute.
|
|
||||||
Path string
|
|
||||||
// Owner is "uid:gid" when the assignment overrides the manifest's — the predecessor's data is
|
|
||||||
// owned by whoever it ran as, and that is one machine's fact.
|
|
||||||
Owner string
|
|
||||||
}
|
|
||||||
|
|
||||||
var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
|
|
||||||
|
|
||||||
// accessRef is how a module names one of its accesses: ${access:<id>}.
|
|
||||||
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
|
|
||||||
|
|
||||||
// Places reads where this node places the module's directories, by directory id.
|
|
||||||
//
|
|
||||||
// It refuses an id the module declares no directory for, a path that is not absolute, and an
|
|
||||||
// owner that is not uid:gid. A directory the assignment does not mention keeps the manifest's
|
|
||||||
// stated path or the node's default layout.
|
|
||||||
func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
|
|
||||||
declared := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "directory" {
|
|
||||||
declared[fmt.Sprint(r["id"])] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := map[string]Placement{}
|
|
||||||
for _, layer := range layers {
|
|
||||||
raw, ok := layer.Values[PlacesSetting]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
blocks, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%s: %s is a { directory: path | { path, owner } } map, and %q set it to something else",
|
|
||||||
m.Module, PlacesSetting, layer.From)
|
|
||||||
}
|
|
||||||
for id, body := range blocks {
|
|
||||||
if !declared[id] {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s places the directory %q, which it does not declare — the setting reaches "+
|
|
||||||
"nothing. It declares %s", m.Module, id, orNothing(namesOfDirs(directoriesOf(m))))
|
|
||||||
}
|
|
||||||
p := out[id]
|
|
||||||
switch v := body.(type) {
|
|
||||||
case string:
|
|
||||||
p.Path = strings.TrimSpace(v)
|
|
||||||
case map[string]any:
|
|
||||||
if path, said := v["path"]; said {
|
|
||||||
text, _ := path.(string)
|
|
||||||
p.Path = strings.TrimSpace(text)
|
|
||||||
}
|
|
||||||
if owner, said := v["owner"]; said {
|
|
||||||
text, _ := owner.(string)
|
|
||||||
if !ownerShape.MatchString(strings.TrimSpace(text)) {
|
|
||||||
return nil, fmt.Errorf("%s places %q with owner %v; an owner is uid:gid, numeric",
|
|
||||||
m.Module, id, owner)
|
|
||||||
}
|
|
||||||
p.Owner = strings.TrimSpace(text)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("%s places %q with %v; a placement is a path, or { path, owner }",
|
|
||||||
m.Module, id, body)
|
|
||||||
}
|
|
||||||
if p.Path == "" {
|
|
||||||
return nil, fmt.Errorf("%s places %q without a path", m.Module, id)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(p.Path, "/") {
|
|
||||||
return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path)
|
|
||||||
}
|
|
||||||
p.Path = strings.TrimRight(p.Path, "/")
|
|
||||||
out[id] = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AccessPlaces reads where this node keeps the operator's data the module accesses, by access id.
|
|
||||||
//
|
|
||||||
// It refuses an id the module declares no access under, and a path that is not absolute. An
|
|
||||||
// access declared by path alone cannot be placed — it has no name to place it by.
|
|
||||||
func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
|
|
||||||
declared := map[string]bool{}
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
if a.ID != "" {
|
|
||||||
declared[a.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := map[string]string{}
|
|
||||||
for _, layer := range layers {
|
|
||||||
raw, ok := layer.Values[AccessesSetting]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
blocks, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%s: %s is a { access: path } map, and %q set it to something else",
|
|
||||||
m.Module, AccessesSetting, layer.From)
|
|
||||||
}
|
|
||||||
for id, body := range blocks {
|
|
||||||
if !declared[id] {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s places the access %q, which it does not declare — the setting reaches "+
|
|
||||||
"nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m)))
|
|
||||||
}
|
|
||||||
path, _ := body.(string)
|
|
||||||
path = strings.TrimSpace(path)
|
|
||||||
if !strings.HasPrefix(path, "/") {
|
|
||||||
return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path",
|
|
||||||
m.Module, id, body)
|
|
||||||
}
|
|
||||||
out[id] = strings.TrimRight(path, "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// placedAccess is one access with the path it resolves to on this node.
|
|
||||||
type placedAccess struct {
|
|
||||||
ID string
|
|
||||||
Path string
|
|
||||||
Mode string
|
|
||||||
}
|
|
||||||
|
|
||||||
// accessesFor is every access of a module with its path on this node: the assignment's where it
|
|
||||||
// placed one, the definition's where it carries a default, and refused where neither says — an
|
|
||||||
// access that resolves to nowhere would reach the machine as a mount of nothing.
|
|
||||||
func accessesFor(m Manifest, layers []Layer) ([]placedAccess, map[string]string, error) {
|
|
||||||
placed, err := AccessPlaces(m, layers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
var out []placedAccess
|
|
||||||
byID := map[string]string{}
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
path := a.Path
|
|
||||||
if a.ID != "" {
|
|
||||||
if at, said := placed[a.ID]; said {
|
|
||||||
path = at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if path == "" {
|
|
||||||
return nil, nil, fmt.Errorf(
|
|
||||||
"%s accesses %q, and nothing says where that is on this node — the definition "+
|
|
||||||
"carries no path (it must not, novox/hq ADR 0112) and the assignment places "+
|
|
||||||
"none. Set %s: {%q: \"/where/it/is\"}",
|
|
||||||
m.Module, a.ID, AccessesSetting, a.ID)
|
|
||||||
}
|
|
||||||
out = append(out, placedAccess{ID: a.ID, Path: path, Mode: a.At()})
|
|
||||||
if a.ID != "" {
|
|
||||||
byID[a.ID] = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, byID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// accessFill resolves every ${access:…} in one string, or refuses a reference naming no access.
|
|
||||||
func accessFill(s string, accesses map[string]string, module string) (string, error) {
|
|
||||||
var missing error
|
|
||||||
out := accessRef.ReplaceAllStringFunc(s, func(ref string) string {
|
|
||||||
id := accessRef.FindStringSubmatch(ref)[1]
|
|
||||||
path, has := accesses[id]
|
|
||||||
if !has {
|
|
||||||
missing = fmt.Errorf(
|
|
||||||
"%s says ${access:%s}, and %s declares no access %q. It declares %s",
|
|
||||||
module, id, module, id, orNothing(namesOfAccessIDs(accesses)))
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
})
|
|
||||||
return out, missing
|
|
||||||
}
|
|
||||||
|
|
||||||
// accessInto fills every ${access:…} a resource carries — in its path, its content, its mounts,
|
|
||||||
// its environment and its env-files — with the path this node resolved for it. The same walk as
|
|
||||||
// dirInto, for the same reason: a literal `${access:x}` reaching the machine would be mounted as
|
|
||||||
// a directory called that.
|
|
||||||
func accessInto(resource map[string]any, accesses map[string]string, module string) error {
|
|
||||||
if !mentionsAccess(resource) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fill := func(s string) (string, error) { return accessFill(s, accesses, module) }
|
|
||||||
var err error
|
|
||||||
if path, ok := resource["path"].(string); ok {
|
|
||||||
if resource["path"], err = fill(path); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if content, ok := resource["content"].(string); ok {
|
|
||||||
if resource["content"], err = fill(content); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []string{"volumes", "env-file"} {
|
|
||||||
list, ok := resource[field].([]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filled := make([]any, len(list))
|
|
||||||
for i, v := range list {
|
|
||||||
filled[i] = v
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
if filled[i], err = fill(s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resource[field] = filled
|
|
||||||
}
|
|
||||||
if env, ok := resource["env"].(map[string]any); ok {
|
|
||||||
filled := make(map[string]any, len(env))
|
|
||||||
for key, v := range env {
|
|
||||||
filled[key] = v
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
if filled[key], err = fill(s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resource["env"] = filled
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mentionsAccess(resource map[string]any) bool {
|
|
||||||
for _, field := range []string{"path", "content"} {
|
|
||||||
if s, ok := resource[field].(string); ok && accessRef.MatchString(s) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []string{"volumes", "env-file"} {
|
|
||||||
if list, ok := resource[field].([]any); ok {
|
|
||||||
for _, v := range list {
|
|
||||||
if s, ok := v.(string); ok && accessRef.MatchString(s) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if env, ok := resource["env"].(map[string]any); ok {
|
|
||||||
for _, v := range env {
|
|
||||||
if s, ok := v.(string); ok && accessRef.MatchString(s) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownerInto gives a placed directory the owner the assignment said, where it said one. The
|
|
||||||
// manifest's owner is what the image expects on any machine; the assignment's is what this
|
|
||||||
// machine's data already is.
|
|
||||||
func ownerInto(resource map[string]any, placed map[string]Placement) {
|
|
||||||
if fmt.Sprint(resource["type"]) != "directory" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if p, ok := placed[fmt.Sprint(resource["id"])]; ok && p.Owner != "" {
|
|
||||||
resource["owner"] = p.Owner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// unknownAccessRefs is every ${access:…} in the definition that names no access the definition
|
|
||||||
// declares by id — refused where the author is, as unknownDirRefs does for directories.
|
|
||||||
func (m Manifest) unknownAccessRefs() []string {
|
|
||||||
declared := map[string]bool{}
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
if a.ID != "" {
|
|
||||||
declared[a.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var problems []string
|
|
||||||
refuse := func(s string, where any) {
|
|
||||||
for _, match := range accessRef.FindAllStringSubmatch(s, -1) {
|
|
||||||
id := match[1]
|
|
||||||
if declared[id] || seen[id] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[id] = true
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says ${access:%s} in %v, and declares no access %q — a reference the mesh "+
|
|
||||||
"cannot place would reach the machine as a literal path",
|
|
||||||
m.Module, id, where, id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
for _, field := range []string{"path", "content"} {
|
|
||||||
if s, ok := r[field].(string); ok {
|
|
||||||
refuse(s, r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, field := range []string{"volumes", "env-file"} {
|
|
||||||
if list, ok := r[field].([]any); ok {
|
|
||||||
for _, v := range list {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
refuse(s, r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if env, ok := r["env"].(map[string]any); ok {
|
|
||||||
for _, v := range env {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
refuse(s, r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(problems)
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func directoriesOf(m Manifest) map[string]string {
|
|
||||||
dirs := map[string]string{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "directory" {
|
|
||||||
dirs[fmt.Sprint(r["id"])] = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dirs
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesOfAccesses(m Manifest) []string {
|
|
||||||
var names []string
|
|
||||||
for _, a := range m.Accesses {
|
|
||||||
if a.ID != "" {
|
|
||||||
names = append(names, fmt.Sprintf("%q", a.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
func namesOfAccessIDs(accesses map[string]string) []string {
|
|
||||||
var names []string
|
|
||||||
for id := range accesses {
|
|
||||||
names = append(names, fmt.Sprintf("%q", id))
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The case novox/hq issue 153 records: an adopted machine keeps its data where the predecessor put
|
|
||||||
// it — a library on its own pool that must never move, a configuration on a second disk owned by
|
|
||||||
// whoever the predecessor ran as. A definition may name none of that (ADR 0112); the assignment
|
|
||||||
// says it, by the ids the definition declared, and the machine receives concrete paths as always.
|
|
||||||
func placeable() Manifest {
|
|
||||||
m := mod("arr", nil, nil, nil)
|
|
||||||
m.Resources = []map[string]any{
|
|
||||||
{"id": "state", "type": "directory", "place": ".", "mode": "0700"},
|
|
||||||
{"id": "config", "type": "directory", "mode": "0755", "owner": "1000:1000"},
|
|
||||||
{"id": "server", "type": "container", "name": "arr", "image": "arr:1",
|
|
||||||
"volumes": []any{"${dir:config}:/config", "${access:series}:/series", "${access:spool}:/downloads:ro"},
|
|
||||||
"env": map[string]any{"SPOOL": "${access:spool}"}},
|
|
||||||
}
|
|
||||||
m.Accesses = []Access{{ID: "series", Mode: AccessReadWrite}, {ID: "spool"}}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func placedBy(values map[string]any) Rendering {
|
|
||||||
return Rendering{Settings: SettingsBy{"arr": {{From: "node anchor", Values: values}}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) {
|
|
||||||
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := got.Declaration(placedBy(map[string]any{
|
|
||||||
PlacesSetting: map[string]any{
|
|
||||||
"config": map[string]any{"path": "/services/arr/config/", "owner": "1001:2000"},
|
|
||||||
},
|
|
||||||
AccessesSetting: map[string]any{
|
|
||||||
"series": "/storage/media/series",
|
|
||||||
"spool": "/storage/downloads",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
seen := map[string]map[string]any{}
|
|
||||||
for _, r := range out {
|
|
||||||
seen[r["id"].(string)] = r
|
|
||||||
}
|
|
||||||
config := seen["arr.config"]
|
|
||||||
if config["path"] != "/services/arr/config" || config["owner"] != "1001:2000" {
|
|
||||||
t.Fatalf("the placed directory is %v %v; want the assignment's path and owner", config["path"], config["owner"])
|
|
||||||
}
|
|
||||||
if seen["arr.state"]["path"] != "/var/lib/arr" {
|
|
||||||
t.Fatalf("an unplaced directory left the default layout: %v", seen["arr.state"]["path"])
|
|
||||||
}
|
|
||||||
var accesses []string
|
|
||||||
for _, r := range out {
|
|
||||||
if r["type"] == "access" {
|
|
||||||
accesses = append(accesses, r["path"].(string)+" "+r["mode"].(string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Join(accesses, ",") != "/storage/media/series read-write,/storage/downloads read" {
|
|
||||||
t.Fatalf("the accesses reached the machine as %v", accesses)
|
|
||||||
}
|
|
||||||
server := seen["arr.server"]
|
|
||||||
mounts := server["volumes"].([]any)
|
|
||||||
if mounts[0] != "/services/arr/config:/config" || mounts[1] != "/storage/media/series:/series" ||
|
|
||||||
mounts[2] != "/storage/downloads:/downloads:ro" {
|
|
||||||
t.Fatalf("the mounts were not filled with the placed paths: %v", mounts)
|
|
||||||
}
|
|
||||||
if server["env"].(map[string]any)["SPOOL"] != "/storage/downloads" {
|
|
||||||
t.Fatalf("the environment was not filled: %v", server["env"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An access declared by id and placed by nobody resolves to nowhere, and that is refused with the
|
|
||||||
// setting to write — not mounted as the literal, not skipped.
|
|
||||||
func TestAnUnplacedAccessIsRefusedByName(t *testing.T) {
|
|
||||||
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = got.Declaration(placedBy(map[string]any{
|
|
||||||
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
|
||||||
}))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), `"spool"`) || !strings.Contains(err.Error(), AccessesSetting) {
|
|
||||||
t.Fatalf("an access nobody placed was not refused by name: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validated like endpoints: an id the module does not declare reaches nothing, and the refusal
|
|
||||||
// says what it does declare; a relative path and a non-numeric owner are refused too.
|
|
||||||
func TestPlacementsAreValidated(t *testing.T) {
|
|
||||||
m := placeable()
|
|
||||||
layers := func(values map[string]any) []Layer { return placedBy(values).Settings["arr"] }
|
|
||||||
|
|
||||||
_, err := Places(m, layers(map[string]any{PlacesSetting: map[string]any{"data": "/mnt/data"}}))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"config"`) {
|
|
||||||
t.Fatalf("placing an undeclared directory was accepted: %v", err)
|
|
||||||
}
|
|
||||||
_, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{"config": "services/arr"}}))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "absolute") {
|
|
||||||
t.Fatalf("a relative placement was accepted: %v", err)
|
|
||||||
}
|
|
||||||
_, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{
|
|
||||||
"config": map[string]any{"path": "/services/arr", "owner": "media"}}}))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "uid:gid") {
|
|
||||||
t.Fatalf("a non-numeric owner was accepted: %v", err)
|
|
||||||
}
|
|
||||||
_, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"series"`) {
|
|
||||||
t.Fatalf("placing an undeclared access was accepted: %v", err)
|
|
||||||
}
|
|
||||||
_, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"series": "media/series"}}))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "absolute") {
|
|
||||||
t.Fatalf("a relative access was accepted: %v", err)
|
|
||||||
}
|
|
||||||
// And the two keys are never stray: they are validated here, not merged into a file.
|
|
||||||
if stray := UnusedSettings(m, layers(map[string]any{
|
|
||||||
PlacesSetting: map[string]any{"config": "/services/arr/config"},
|
|
||||||
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
|
||||||
})); len(stray) != 0 {
|
|
||||||
t.Fatalf("the placement keys were reported as unused: %v", stray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A definition that carries a path still works, as the default the assignment may replace — and
|
|
||||||
// the assignment's placement wins where both say.
|
|
||||||
func TestADefinitionsPathIsTheDefaultTheAssignmentReplaces(t *testing.T) {
|
|
||||||
m := placeable()
|
|
||||||
m.Accesses = []Access{{ID: "series", Path: "/services/media/series", Mode: AccessReadWrite}, {ID: "spool", Path: "/services/media/downloads"}}
|
|
||||||
got, err := Resolve(shelf(m), []string{"arr"}, workstation(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := got.Declaration(placedBy(map[string]any{
|
|
||||||
PlacesSetting: map[string]any{"config": "/services/arr/config"},
|
|
||||||
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
|
||||||
}))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var paths []string
|
|
||||||
for _, r := range out {
|
|
||||||
if r["type"] == "access" {
|
|
||||||
paths = append(paths, r["path"].(string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Join(paths, ",") != "/storage/media/series,/services/media/downloads" {
|
|
||||||
t.Fatalf("placed one, defaulted the other: got %v", paths)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A reference to an access the definition does not declare is refused where the author is.
|
|
||||||
func TestAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{
|
|
||||||
"module": "arr", "version": "1",
|
|
||||||
"accesses": [{"id": "series", "mode": "read-write"}],
|
|
||||||
"resources": [
|
|
||||||
{"id": "state", "type": "directory", "place": ".", "mode": "0700"},
|
|
||||||
{"id": "server", "type": "container", "name": "arr", "image": "arr:1",
|
|
||||||
"volumes": ["${access:movies}:/movies"]}
|
|
||||||
]}`))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "${access:movies}") {
|
|
||||||
t.Fatalf("a reference to an undeclared access was accepted: %v", err)
|
|
||||||
}
|
|
||||||
_, err = ParseManifest([]byte(`{"module": "arr", "version": "1", "accesses": [{"mode": "read"}]}`))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "neither an id nor a path") {
|
|
||||||
t.Fatalf("an access with no id and no path was accepted: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -98,7 +98,8 @@ func portInto(resource map[string]any, module string, listens []Listening, with
|
|||||||
|
|
||||||
// **A fresh map, and only when something changes.** This map came out of the module's
|
// **A fresh map, and only when something changes.** This map came out of the module's
|
||||||
// manifest and the resource around it is a shallow copy, so filling a value in place would
|
// manifest and the resource around it is a shallow copy, so filling a value in place would
|
||||||
// change what the catalogue holds for every other machine running the module.
|
// change what the catalogue holds for every other machine running the module — the trap
|
||||||
|
// withMeshNames is written to avoid, one field along.
|
||||||
var filled map[string]any
|
var filled map[string]any
|
||||||
for _, key := range named {
|
for _, key := range named {
|
||||||
written, ok := env[key].(string)
|
written, ok := env[key].(string)
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a provider receives is composed once, and issued twice: as its received file, and in its
|
|
||||||
// membership on the bus (novox/hq ADR 0167). The two are the same list, so a proxy reading the bus
|
|
||||||
// and one reading the file serve the same routes — including the port the machine published, which
|
|
||||||
// is the same-node fix the file already carries.
|
|
||||||
func TestWhatAProviderReceivesIsTheSameOnTheBusAsInItsFile(t *testing.T) {
|
|
||||||
gitea := Manifest{
|
|
||||||
Module: "gitea", Version: "1",
|
|
||||||
Listens: []Listening{{Port: 3000, Protocol: "tcp", From: FromMesh}},
|
|
||||||
Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
r, err := Resolve(shelf(gitea, routeProxy(), stepCA()),
|
|
||||||
[]string{"gitea", "route-proxy", "step-ca"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
composed, err := r.Compose(Rendering{Ports: map[string]map[int]int{"gitea": {3000: 20000}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
file := fileNamed(composed.Resources, "route-proxy.received-route")
|
|
||||||
if file == nil {
|
|
||||||
t.Fatal("the proxy was given no routes file")
|
|
||||||
}
|
|
||||||
var written struct {
|
|
||||||
Given []Contribution `json:"given"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(file["content"].(string)), &written); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
issued, said := composed.Received["route-proxy"]["route"]
|
|
||||||
if !said {
|
|
||||||
t.Fatalf("nothing is issued for the proxy to receive on the bus: %v", composed.Received)
|
|
||||||
}
|
|
||||||
// Compared as JSON, which is what both are once they leave the controller.
|
|
||||||
a, _ := json.Marshal(written.Given)
|
|
||||||
b, _ := json.Marshal(issued)
|
|
||||||
var fromFile, fromBus any
|
|
||||||
_ = json.Unmarshal(a, &fromFile)
|
|
||||||
_ = json.Unmarshal(b, &fromBus)
|
|
||||||
if !reflect.DeepEqual(fromFile, fromBus) {
|
|
||||||
t.Errorf("the bus and the file disagree about the routes:\nfile %s\nbus %s", a, b)
|
|
||||||
}
|
|
||||||
if len(issued) != 1 {
|
|
||||||
t.Fatalf("expected one route on the bus, got %v", issued)
|
|
||||||
}
|
|
||||||
if port, ok := asPort(issued[0].Values["port"]); !ok || port != 20000 {
|
|
||||||
t.Errorf("the bus carries a port nothing listens on: %v", issued[0].Values["port"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module that receives nothing is issued nothing to receive.
|
|
||||||
if _, any := composed.Received["gitea"]; any {
|
|
||||||
t.Errorf("a module that receives nothing was issued something: %v", composed.Received["gitea"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,12 +48,11 @@ type World struct {
|
|||||||
Holdings []Held
|
Holdings []Held
|
||||||
// Offered is what other nodes provide at mesh scope, and everything needed to use it.
|
// Offered is what other nodes provide at mesh scope, and everything needed to use it.
|
||||||
Offered map[string][]Provider
|
Offered map[string][]Provider
|
||||||
// Pinned is which provider this machine was told to get a provision from, by name: a module and
|
// Pinned is which node this machine was told to get a provision from, by name. Only consulted
|
||||||
// the node it runs on, both (novox/hq #258). Only consulted when more than one could answer -- a
|
// when more than one node could answer -- a choice recorded before it was needed should not
|
||||||
// choice recorded before it was needed should not start meaning something the day a second
|
// start meaning something the day a second provider appears, and one recorded and then made
|
||||||
// provider appears, and one recorded and then made unnecessary should not quietly stop applying
|
// unnecessary should not quietly stop applying either.
|
||||||
// either.
|
Pinned map[string]string
|
||||||
Pinned map[string]Chosen
|
|
||||||
// Licences is every provision answered by a **record rather than a node**, by provision name.
|
// Licences is every provision answered by a **record rather than a node**, by provision name.
|
||||||
//
|
//
|
||||||
// novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public
|
// novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public
|
||||||
@@ -180,10 +179,6 @@ type Needed struct {
|
|||||||
// for one requirement (ADR 0094); empty for the ordinary one. Part of what identifies the pair
|
// for one requirement (ADR 0094); empty for the ordinary one. Part of what identifies the pair
|
||||||
// credential, so two secrets from one provider to one module are two secrets.
|
// credential, so two secrets from one provider to one module are two secrets.
|
||||||
Local string
|
Local string
|
||||||
// SharedOwn is set when the provision's credential is one of the provider's own secrets, shared
|
|
||||||
// by every consumer (novox/hq ADR 0158): the name of that secret in the provider's definition.
|
|
||||||
// The plan mints the pair's copy from the provider's value rather than a value of its own.
|
|
||||||
SharedOwn string
|
|
||||||
// Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh
|
// Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh
|
||||||
// token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty
|
// token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty
|
||||||
// Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting
|
// Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting
|
||||||
@@ -261,10 +256,6 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
// still "choose one" after somebody has chosen one. That makes the remedy useless, and it is
|
// still "choose one" after somebody has chosen one. That makes the remedy useless, and it is
|
||||||
// how this read when first used.
|
// how this read when first used.
|
||||||
satisfied := map[string]bool{}
|
satisfied := map[string]bool{}
|
||||||
// Which modules a person assigned here, hostable. The walk marks a module chosen only when it
|
|
||||||
// reaches it, and a consumer may be reached before the provider beside it — so the provider of
|
|
||||||
// something already satisfied is looked for among these as well as among the chosen.
|
|
||||||
assignedHere := map[string]bool{}
|
|
||||||
|
|
||||||
// Everything a person assigned goes in first, except what this machine cannot run. Those are
|
// Everything a person assigned goes in first, except what this machine cannot run. Those are
|
||||||
// choices already made, and a requirement one of them answers is not a choice to put back to
|
// choices already made, and a requirement one of them answers is not a choice to put back to
|
||||||
@@ -289,7 +280,6 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
for _, o := range m.Offers() {
|
for _, o := range m.Offers() {
|
||||||
satisfied[o] = true
|
satisfied[o] = true
|
||||||
}
|
}
|
||||||
assignedHere[a] = true
|
|
||||||
}
|
}
|
||||||
because[a] = "assigned"
|
because[a] = "assigned"
|
||||||
queue = append(queue, a)
|
queue = append(queue, a)
|
||||||
@@ -317,51 +307,6 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
// commonest arrangement of all — a service and its database on one node — the weakest
|
// commonest arrangement of all — a service and its database on one node — the weakest
|
||||||
// handling, silently.
|
// handling, silently.
|
||||||
if satisfied[want] && !isModule(catalogue, want) {
|
if satisfied[want] && !isModule(catalogue, want) {
|
||||||
here := func(name string) bool { return chosen[name] || assignedHere[name] }
|
|
||||||
local := providersHere(catalogue, here, want)
|
|
||||||
// Which of them it matters to choose between. A plain capability — a shell, a display
|
|
||||||
// server — asks nothing of whoever answers it, and three shells beside an editor are
|
|
||||||
// not a choice to put to anybody. One that grants a credential, or serves a fact the
|
|
||||||
// consumer cannot guess, becomes a binding, and a binding is to one provider.
|
|
||||||
matter := local
|
|
||||||
if !brokered[want] {
|
|
||||||
matter = nil
|
|
||||||
for _, name := range local {
|
|
||||||
if _, ok := catalogue[name].Serves[want]; ok {
|
|
||||||
matter = append(matter, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var by Manifest
|
|
||||||
switch len(matter) {
|
|
||||||
case 0:
|
|
||||||
// Nothing to bind to; satisfied by its presence, as it was.
|
|
||||||
case 1:
|
|
||||||
by = catalogue[matter[0]]
|
|
||||||
default:
|
|
||||||
// Two modules on this machine answer it. Taking whichever a map walk met first
|
|
||||||
// was the rule until novox/hq #258 — random, per plan — and the same stance as
|
|
||||||
// across machines applies: ambiguity is refused, never resolved by picking.
|
|
||||||
//
|
|
||||||
// **Not in the first pass.** That pass exists only to answer *what does this node
|
|
||||||
// offer*, and refusing there makes the machine vanish rather than report a problem
|
|
||||||
// (the sibling case below says why): every other node then loses what this one
|
|
||||||
// provides — the vault, the identity provider — and refuses for a fault that is
|
|
||||||
// this node's to settle. The second pass refuses it properly, where it is asked.
|
|
||||||
if world.Unchecked {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
c, pinned := world.Pinned[want]
|
|
||||||
if !pinned || c.Node != node.Name || !oneOf(matter, c.Module) {
|
|
||||||
reported[want] = true
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s provides %q %d times, wanted by %s — say which with `pin %s %s %s <module>`: %s",
|
|
||||||
node.Name, want, len(matter), because[want], node.Name, want, node.Name,
|
|
||||||
strings.Join(matter, ", ")))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
by = catalogue[c.Module]
|
|
||||||
}
|
|
||||||
if brokered[want] {
|
if brokered[want] {
|
||||||
// Answered here, and still a need: the provider is this node.
|
// Answered here, and still a need: the provider is this node.
|
||||||
//
|
//
|
||||||
@@ -377,9 +322,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
}
|
}
|
||||||
needs = append(needs, Needed{
|
needs = append(needs, Needed{
|
||||||
Name: want, From: node.Name, At: at,
|
Name: want, From: node.Name, At: at,
|
||||||
Serves: servedByOne(by, want), For: because[want],
|
Serves: servedHere(catalogue, chosen, want), For: because[want]})
|
||||||
SharedOwn: sharedByOne(by, want)})
|
} else if served := servedHere(catalogue, chosen, want); len(served) > 0 {
|
||||||
} else if served := servedByOne(by, want); len(served) > 0 {
|
|
||||||
// Answered here with no credential to mint, but the provider serves facts the
|
// Answered here with no credential to mint, but the provider serves facts the
|
||||||
// consumer cannot guess — a port, a model name — and so still needs a binding.
|
// consumer cannot guess — a port, a model name — and so still needs a binding.
|
||||||
// **The reachability rule does not apply**: both ends are on this same machine, so
|
// **The reachability rule does not apply**: both ends are on this same machine, so
|
||||||
@@ -409,7 +353,11 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
if brokered[want] {
|
if brokered[want] {
|
||||||
reported[want] = true
|
reported[want] = true
|
||||||
where := world.Offered[want]
|
where := world.Offered[want]
|
||||||
names := providerNames(where)
|
names := make([]string, 0, len(where))
|
||||||
|
for _, p := range where {
|
||||||
|
names = append(names, p.Node)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
take := func(p Provider) {
|
take := func(p Provider) {
|
||||||
if node.At != "" && p.At == "" || node.At == "" && p.At != "" || node.At == "" && p.At == "" {
|
if node.At != "" && p.At == "" || node.At == "" && p.At != "" || node.At == "" && p.At == "" {
|
||||||
// One of them is not on the private network, so there is no path between
|
// One of them is not on the private network, so there is no path between
|
||||||
@@ -421,12 +369,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
node.Name, want, p.Node, meshNetwork))
|
node.Name, want, p.Node, meshNetwork))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shared := ""
|
|
||||||
if pm, known := catalogue[p.Module]; known {
|
|
||||||
shared, _ = pm.SharedCredentialOf(want)
|
|
||||||
}
|
|
||||||
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At,
|
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At,
|
||||||
Serves: p.Serves, For: because[want], SharedOwn: shared})
|
Serves: p.Serves, For: because[want]})
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case world.Unchecked:
|
case world.Unchecked:
|
||||||
@@ -443,17 +387,17 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
"nothing in this mesh provides %q, wanted by %s %s",
|
"nothing in this mesh provides %q, wanted by %s %s",
|
||||||
want, because[want], remedy))
|
want, because[want], remedy))
|
||||||
case len(where) == 1:
|
case len(where) == 1:
|
||||||
if c, pinned := world.Pinned[want]; pinned && !c.matches(where[0]) {
|
if chosenNode, pinned := world.Pinned[want]; pinned && chosenNode != where[0].Node {
|
||||||
// One provider, and it is not the one this machine was told to use. Silently
|
// One provider, and it is not the one this machine was told to use. Silently
|
||||||
// using the other would be the mesh overruling a choice somebody made.
|
// using the other would be the mesh overruling a choice somebody made.
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s was told to get %q from %s, and only %s provides it",
|
"%s was told to get %q from %s, and only %s provides it",
|
||||||
node.Name, want, c, nameOf(where[0])))
|
node.Name, want, chosenNode, where[0].Node))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
take(where[0])
|
take(where[0])
|
||||||
default:
|
default:
|
||||||
c, pinned := world.Pinned[want]
|
chosenNode, pinned := world.Pinned[want]
|
||||||
if !pinned {
|
if !pinned {
|
||||||
// **The seat's holder answers, when a seat delivers this** (novox/hq ADR 0110).
|
// **The seat's holder answers, when a seat delivers this** (novox/hq ADR 0110).
|
||||||
// Not a guess, which ADR 0009 refuses: the choice was made once, mesh-wide, by
|
// Not a guess, which ADR 0009 refuses: the choice was made once, mesh-wide, by
|
||||||
@@ -465,32 +409,27 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%d providers of %q, wanted by %s — say which with `pin %s %s <node> <module>`: %s",
|
"%d nodes provide %q, wanted by %s — say which with `pin %s %s <node>`: %s",
|
||||||
len(where), want, because[want], node.Name, want,
|
len(where), want, because[want], node.Name, want,
|
||||||
strings.Join(names, ", ")))
|
strings.Join(names, ", ")))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
matching := c.among(where)
|
var chosen *Provider
|
||||||
switch len(matching) {
|
for i, w := range where {
|
||||||
case 0:
|
if w.Node == chosenNode {
|
||||||
// Pointed at a provider that does not answer this. Refused rather than
|
chosen = &where[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chosen == nil {
|
||||||
|
// Pointed at a machine that does not answer this. Refused rather than
|
||||||
// falling back to another: a fallback would quietly move somebody's data to
|
// falling back to another: a fallback would quietly move somebody's data to
|
||||||
// a machine they did not choose, which is the whole reason this is asked.
|
// a machine they did not choose, which is the whole reason this is asked.
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s was told to get %q from %s, and %s does not provide it — these do: %s",
|
"%s was told to get %q from %s, and %s does not provide it — these do: %s",
|
||||||
node.Name, want, c, c, strings.Join(names, ", ")))
|
node.Name, want, chosenNode, chosenNode, strings.Join(names, ", ")))
|
||||||
case 1:
|
break
|
||||||
take(matching[0])
|
|
||||||
default:
|
|
||||||
// A record naming only the node, from before a pin named the module, and that
|
|
||||||
// node answers twice. This once took the last one listed (novox/hq #258): a
|
|
||||||
// coin flip, handed to whoever reads the certificate it chose.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s was told to get %q from %s, and %s provides it %d times — say which with "+
|
|
||||||
"`pin %s %s %s <module>`: %s",
|
|
||||||
node.Name, want, c, c.Node, len(matching), node.Name, want, c.Node,
|
|
||||||
strings.Join(providerNames(matching), ", ")))
|
|
||||||
}
|
}
|
||||||
|
take(*chosen)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -649,6 +588,17 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
// need that is never created is a binding the consumer never gets. It is right about that from the
|
// need that is never created is a binding the consumer never gets. It is right about that from the
|
||||||
// manifest alone, which is why walking the catalogue mid-resolution is enough here and is not
|
// manifest alone, which is why walking the catalogue mid-resolution is enough here and is not
|
||||||
// enough for the values.
|
// enough for the values.
|
||||||
|
func servedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) map[string]any {
|
||||||
|
for name, m := range catalogue {
|
||||||
|
if !chosen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := m.Serves[want]; ok {
|
||||||
|
return ServedOn(m, want, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// isModule reports whether a name is a module in its own right rather than only something
|
// isModule reports whether a name is a module in its own right rather than only something
|
||||||
// modules provide.
|
// modules provide.
|
||||||
@@ -757,14 +707,9 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel
|
|||||||
}
|
}
|
||||||
switch h.Scope {
|
switch h.Scope {
|
||||||
case ScopeMesh:
|
case ScopeMesh:
|
||||||
// Both claim and nobody is on record, or this refusal could not have happened.
|
|
||||||
// The remedy is the handover that records the holder (novox/hq ADR 0131,
|
|
||||||
// 04-ISSUES/170), so it is named here rather than left to be found.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s on %s claims %q, which %s on %s already holds — one per mesh. Nothing is "+
|
"%s on %s claims %q, which %s on %s already holds — one per mesh",
|
||||||
"on record for it; `seat %s --to %s/%s` records the holder, and the other "+
|
h.Module, node.Name, h.Claim, e.Module, e.Node))
|
||||||
"assignment then stands beside it, eligible and silent",
|
|
||||||
h.Module, node.Name, h.Claim, e.Module, e.Node, h.Claim, e.Node, e.Module))
|
|
||||||
case ScopeSite:
|
case ScopeSite:
|
||||||
if node.Site != "" && node.Site == e.Site {
|
if node.Site != "" && node.Site == e.Site {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
@@ -841,9 +786,6 @@ func checkResources(modules []Manifest) []string {
|
|||||||
// which is what lets the stack in 04-ISSUES/036 co-resolve.
|
// which is what lets the stack in 04-ISSUES/036 co-resolve.
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
for _, a := range m.Accesses {
|
for _, a := range m.Accesses {
|
||||||
if a.Path == "" {
|
|
||||||
continue // placed by the assignment; nothing to compare at registration
|
|
||||||
}
|
|
||||||
switch other := ownedPath[a.Path]; other {
|
switch other := ownedPath[a.Path]; other {
|
||||||
case "":
|
case "":
|
||||||
// Nobody owns it — the ordinary, correct case for shared data.
|
// Nobody owns it — the ordinary, correct case for shared data.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
||||||
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
"\nlisten-address=127.0.0.1\n", "\ninterface=mesh0\n", "\nbind-dynamic\n",
|
||||||
"\ndomain-needed\n", "\nbogus-priv\n",
|
"\ndomain-needed\n", "\nbogus-priv\n",
|
||||||
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
||||||
} {
|
} {
|
||||||
@@ -56,14 +56,6 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config)
|
t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// By address and never by interface: dnsmasq admits a query by the interface it arrives on
|
|
||||||
// when told one, and a container's query to the private address arrives on the runtime's
|
|
||||||
// bridge — `interface=mesh0` dropped every such query, silently (novox/hq issue 110).
|
|
||||||
for _, line := range strings.Split(config, "\n") {
|
|
||||||
if strings.HasPrefix(line, "interface=") {
|
|
||||||
t.Errorf("the resolver answers by interface, so a container's query on a bridge is dropped: %s", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Not .53 or .54, which systemd-resolved holds; and not .55 any more, which was a convention
|
// Not .53 or .54, which systemd-resolved holds; and not .55 any more, which was a convention
|
||||||
// beside the one every machine already followed — the predecessor's resolv.conf says .1.
|
// beside the one every machine already followed — the predecessor's resolv.conf says .1.
|
||||||
for _, taken := range []string{"127.0.0.53", "127.0.0.54", "127.0.0.55"} {
|
for _, taken := range []string{"127.0.0.53", "127.0.0.54", "127.0.0.55"} {
|
||||||
@@ -145,39 +137,23 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
|||||||
t.Errorf("the daemon does not restart on its configuration and the machines file both: %v", service["restart-on"])
|
t.Errorf("the daemon does not restart on its configuration and the machines file both: %v", service["restart-on"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// The runtime's own file, written into (novox/hq ADR 0102) with the keys this module states:
|
// The runtime's own file, written into (novox/hq ADR 0102) with the one key this module states.
|
||||||
// where containers resolve, and that a restart keeps them running — because the runtime reads
|
|
||||||
// `dns` only when it starts, and the one restart that needs is the operator's (issue 110).
|
|
||||||
runtime := ids["dnsmasq.runtime-dns"]
|
runtime := ids["dnsmasq.runtime-dns"]
|
||||||
if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" {
|
if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" {
|
||||||
t.Fatalf("the runtime's dns is not written into its file: %v", runtime)
|
t.Fatalf("the runtime's dns is not written into its file: %v", runtime)
|
||||||
}
|
}
|
||||||
var keys map[string]any
|
var keys map[string][]string
|
||||||
if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil {
|
if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil {
|
||||||
t.Fatalf("the runtime's keys are not JSON: %v", err)
|
t.Fatalf("the runtime's keys are not JSON: %v", err)
|
||||||
}
|
}
|
||||||
dns, _ := keys["dns"].([]any)
|
if len(keys) != 1 || len(keys["dns"]) != 1 || keys["dns"][0] != "10.42.0.1" {
|
||||||
if len(keys) != 2 || len(dns) != 1 || dns[0] != "10.42.0.1" || keys["live-restore"] != true {
|
t.Errorf("the runtime is pointed at %v; containers resolve at this machine's own private-network address, and nothing else is written", keys)
|
||||||
t.Errorf("the runtime is given %v; containers resolve at this machine's own private-network address, a restart keeps them, and nothing else is written", keys)
|
|
||||||
}
|
}
|
||||||
// The runtime is reloaded when that file changes, and never restarted: a restart stops every
|
|
||||||
// container on the machine (ADR 0102), and a reload is what turns live-restore on.
|
|
||||||
var reloaded bool
|
|
||||||
for _, r := range out {
|
for _, r := range out {
|
||||||
if r["type"] != "service" || r["unit"] != "docker.service" {
|
if r["type"] == "service" && r["unit"] == "docker.service" && r["id"] != "" &&
|
||||||
continue
|
strings.HasPrefix(r["id"].(string), "dnsmasq.") {
|
||||||
|
t.Errorf("the resolver orders the runtime restarted or reloaded, which stops every container (ADR 0102) or does nothing for dns: %v", r)
|
||||||
}
|
}
|
||||||
if _, restarts := r["restart-on"]; restarts {
|
|
||||||
t.Errorf("the resolver orders the runtime restarted, which stops every container (ADR 0102): %v", r)
|
|
||||||
}
|
|
||||||
for _, on := range asStrings(r["reload-on"]) {
|
|
||||||
if on == "dnsmasq.runtime-dns" {
|
|
||||||
reloaded = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !reloaded {
|
|
||||||
t.Errorf("the runtime is not reloaded when its file changes, so live-restore never takes effect")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolv := ids["resolv-conf.resolv"]
|
resolv := ids["resolv-conf.resolv"]
|
||||||
|
|||||||
@@ -162,13 +162,6 @@ func renderRoster(tmpl string, view rosterView) (string, error) {
|
|||||||
func entriesFrom(addresses, accounts map[string]string, suffix string) []rosterEntry {
|
func entriesFrom(addresses, accounts map[string]string, suffix string) []rosterEntry {
|
||||||
out := make([]rosterEntry, 0, len(addresses))
|
out := make([]rosterEntry, 0, len(addresses))
|
||||||
for _, name := range sortedNames(addresses) {
|
for _, name := range sortedNames(addresses) {
|
||||||
if routed(name, suffix) {
|
|
||||||
// A routed name is already a full name under a public domain, and it has no mesh
|
|
||||||
// form: appending the suffix made `<name>.<suffix>`, which every machine's hosts file
|
|
||||||
// carried and nothing served (novox/hq issue 157). It is published as itself, once.
|
|
||||||
out = append(out, rosterEntry{Name: name, FQDN: name, Address: addresses[name], Account: accounts[name]})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
internal, bare := meshName(name, suffix)
|
internal, bare := meshName(name, suffix)
|
||||||
// The account is looked up by whichever key the caller keys accounts on — the internal name
|
// The account is looked up by whichever key the caller keys accounts on — the internal name
|
||||||
// or the bare one — so a template gets the right login however the maps were built.
|
// or the bare one — so a template gets the right login however the maps were built.
|
||||||
@@ -194,14 +187,6 @@ func meshName(name, suffix string) (internal, bare string) {
|
|||||||
return name + dotted, name
|
return name + dotted, name
|
||||||
}
|
}
|
||||||
|
|
||||||
// routed says whether a name the mesh serves is a routed public name rather than a machine's: it
|
|
||||||
// carries a domain of its own and not the mesh's suffix. A machine's name is bare (`homer`) or
|
|
||||||
// internal (`homer.internal`); anything else with a dot in it was composed under a public domain.
|
|
||||||
func routed(name, suffix string) bool {
|
|
||||||
dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".")
|
|
||||||
return strings.Contains(name, ".") && !strings.HasSuffix(name, dotted)
|
|
||||||
}
|
|
||||||
|
|
||||||
// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down.
|
// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down.
|
||||||
// The one place the default is written, so a fact and a name cannot disagree about it.
|
// The one place the default is written, so a fact and a name cannot disagree about it.
|
||||||
func suffixOr(suffix string) string {
|
func suffixOr(suffix string) string {
|
||||||
|
|||||||
@@ -258,24 +258,3 @@ func TestAHomeFactIsSkippedWhereThereIsNoAccount(t *testing.T) {
|
|||||||
t.Fatalf("a home fact was placed on a machine with no operator account: %v", given)
|
t.Fatalf("a home fact was placed on a machine with no operator account: %v", given)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A routed name is already a full name under a public domain and has no mesh form. Appending the
|
|
||||||
// suffix to it made `git.example.tld.internal` — carried by every machine's hosts file, served by
|
|
||||||
// nothing, and refused by the proxy at the handshake (novox/hq issue 157). It is published as
|
|
||||||
// itself, and only a machine has a bare name beside its full one.
|
|
||||||
func TestARoutedNameIsPublishedAsItselfAndNotSuffixed(t *testing.T) {
|
|
||||||
names := map[string]string{"homer.internal": "10.42.0.1", "git.example.tld": "10.42.0.1"}
|
|
||||||
tmpl := RosterFile{Path: "/f", Template: "{{range .Names}}{{.FQDN}} {{.Name}}\n{{end}}"}
|
|
||||||
out, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}},
|
|
||||||
Resolution{Node: "homer"}, names, map[string]string{"homer.internal": "10.42.0.1"}, nil, "internal")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := out[0]["content"].(string)
|
|
||||||
if strings.Contains(got, "tld.internal") {
|
|
||||||
t.Fatalf("the routed name was given a suffixed alias that nothing serves:\n%s", got)
|
|
||||||
}
|
|
||||||
if !strings.Contains(got, "git.example.tld git.example.tld\n") || !strings.Contains(got, "homer.internal homer\n") {
|
|
||||||
t.Fatalf("the roster does not carry the routed name as itself beside the machine's two forms:\n%s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -198,59 +198,37 @@ func TestTheApexLabelComposesToTheBarePrivateAddress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// novox/hq ADR 0066 propagate, by ADR 0148's means: a granted route name is published into the
|
|
||||||
// machine's roster mapped to the node that serves it, beside the `<node>.internal` names, and the
|
|
||||||
// machine's resolver answers it to every container. Nothing is written into the container itself —
|
|
||||||
// a routed name that moved would otherwise be wrong inside every container until each was recreated.
|
|
||||||
func TestARoutedNameResolvesToTheServingNode(t *testing.T) {
|
func TestARoutedNameResolvesToTheServingNode(t *testing.T) {
|
||||||
names := map[string]string{
|
// novox/hq ADR 0066 propagate: a granted route name is published into internal resolution,
|
||||||
"anchor.internal": "10.42.0.1",
|
// mapped to the node that serves it, alongside the `<node>.internal` names — so every
|
||||||
"git.example.tld": "10.42.0.1",
|
// container, and an in-mesh ACME validator, resolves a routed name to the proxy that serves it.
|
||||||
}
|
// The names map is what withMeshNames writes into every container as `--add-host`; a route name
|
||||||
|
// mapped to the serving node's address rides the same mechanism.
|
||||||
got := containersOf(t, Resolution{Node: "anchor", Modules: []Manifest{{
|
got := containersOf(t, Resolution{Node: "anchor", Modules: []Manifest{{
|
||||||
Module: "app",
|
Module: "app",
|
||||||
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
||||||
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}},
|
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}},
|
||||||
}}}, Rendering{Names: names})
|
}}}, Rendering{Names: map[string]string{
|
||||||
|
"anchor.internal": "10.42.0.1",
|
||||||
|
"git.example.tld": "10.42.0.1",
|
||||||
|
}})
|
||||||
if len(got) != 1 {
|
if len(got) != 1 {
|
||||||
t.Fatalf("expected one container, got %d", len(got))
|
t.Fatalf("expected one container, got %d", len(got))
|
||||||
}
|
}
|
||||||
if given := namesOf(got[0]); len(given) != 0 {
|
given := namesOf(got[0])
|
||||||
t.Fatalf("the routed name was copied into the container, where it would go stale: %v", given)
|
var sawNode, sawRoute bool
|
||||||
}
|
for _, h := range given {
|
||||||
var sawRoute bool
|
if h == "anchor.internal:10.42.0.1" {
|
||||||
for _, e := range entriesFrom(names, nil, "internal") {
|
sawNode = true
|
||||||
if e.Name == "git.example.tld" && e.Address == "10.42.0.1" {
|
}
|
||||||
|
if h == "git.example.tld:10.42.0.1" {
|
||||||
sawRoute = true
|
sawRoute = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !sawNode {
|
||||||
|
t.Fatalf("the container lost the mesh's node names: %v", given)
|
||||||
|
}
|
||||||
if !sawRoute {
|
if !sawRoute {
|
||||||
t.Fatalf("the routed name is not in the roster the machine's resolver answers from")
|
t.Fatalf("the routed name was not published to the serving node: %v", given)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// novox/hq issue 139, ADR 0151: `<label>.<node>.internal` is answered by every machine's resolver as
|
|
||||||
// "anything under that node's name goes to that node", so the node in a route's internal name must
|
|
||||||
// be the one whose proxy answers it. Composed under the consumer's own name, a route served from
|
|
||||||
// another machine got an internal name that resolved to a machine with nothing listening, while the
|
|
||||||
// public name — published at the serving node's address — worked.
|
|
||||||
func TestARoutesInternalNameIsComposedUnderTheNodeThatServesIt(t *testing.T) {
|
|
||||||
hub := proxy()
|
|
||||||
hub.Provides = FromAnywhere("reverse-proxy")
|
|
||||||
got, err := Resolve(shelf(hub, labelled("board", "git", 8080)), []string{"board"},
|
|
||||||
withPrivateAddress("laptop.internal"), World{Offered: map[string][]Provider{
|
|
||||||
"reverse-proxy": {{Node: "anchor", At: "anchor.internal", Module: "traefik"}},
|
|
||||||
}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Gathered the way the control plane gathers a consumer's contribution for a provider on
|
|
||||||
// another machine.
|
|
||||||
values, asks, err := got.ContributionsFrom("reverse-proxy", "board", nil)
|
|
||||||
if err != nil || !asks {
|
|
||||||
t.Fatalf("the route was not contributed: %v %v", asks, err)
|
|
||||||
}
|
|
||||||
if values["internal-name"] != "git.anchor.internal" {
|
|
||||||
t.Fatalf("the internal name does not say where the request arrives: %v", values)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ type Seat struct {
|
|||||||
// today — which the bus refuses, because a namespace belongs to who it is named for.
|
// today — which the bus refuses, because a namespace belongs to who it is named for.
|
||||||
Accepts []string
|
Accepts []string
|
||||||
Emits []string
|
Emits []string
|
||||||
// Serves carries each verb in full — name, description, schema — because a role's tools are the
|
Serves []string
|
||||||
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
|
|
||||||
Serves []Verb
|
|
||||||
// Decision is the record that made it a seat.
|
// Decision is the record that made it a seat.
|
||||||
Decision string
|
Decision string
|
||||||
}
|
}
|
||||||
@@ -53,37 +51,16 @@ var defaultSeats = []Seat{
|
|||||||
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
|
||||||
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
|
||||||
// so no work queue is raised for it — only what its holder may say.
|
// so no work queue is raised for it — only what its holder may say.
|
||||||
// And it serves the mesh's own verbs as the seat's tools (novox/hq ADR 0154): `status`, `push`,
|
{Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
||||||
// `assign` and the rest are a role's interface, not a container's, and stay addressable while
|
Emits: []string{"applied", "refused", "built-before"}},
|
||||||
// the control plane is replaced.
|
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"},
|
||||||
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
|
||||||
Emits: []string{"applied", "refused", "built-before"},
|
|
||||||
Serves: ControllerVerbs},
|
|
||||||
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
|
|
||||||
// served by whichever module holds the seat with tools of these names.
|
|
||||||
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "databases", Description: "Every database the store holds, with its on-disk size.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "query", Description: "One read-only statement against one database the store holds.",
|
|
||||||
Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"},
|
|
||||||
[]string{"database", "sql"})},
|
|
||||||
}},
|
|
||||||
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
|
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
|
||||||
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
|
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
|
||||||
// the mesh's own transport. ADR 0128 then made that connection something a module requires
|
// the mesh's own transport. ADR 0128 then made that connection something a module requires
|
||||||
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
|
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
|
||||||
// connection would mint a credential for each.
|
// connection would mint a credential for each.
|
||||||
{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
|
{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
|
||||||
// The vault: the controller seals every minted credential with what it provides, which is the
|
{Name: "the-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
||||||
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
|
|
||||||
// second claimant, refused by name, rather than a candidate for a pin.
|
|
||||||
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161"},
|
|
||||||
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
|
||||||
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
|
||||||
// images and archives, by digest — which is why the provision is the artifact store and not an
|
|
||||||
// image registry.
|
|
||||||
{Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
|
||||||
{Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"},
|
{Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"},
|
||||||
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
||||||
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
||||||
@@ -93,11 +70,8 @@ var defaultSeats = []Seat{
|
|||||||
// A build is work submitted to this role and its outcome is the role's own event (ADR 0129).
|
// A build is work submitted to this role and its outcome is the role's own event (ADR 0129).
|
||||||
// One publish reaches whoever asked, the controller that records it, and the catalogue that
|
// One publish reaches whoever asked, the controller that records it, and the catalogue that
|
||||||
// places it in the graph — what the old bus's shared exchange did for free.
|
// places it in the graph — what the old bus's shared exchange did for free.
|
||||||
// A build says what it does as it does it (novox/hq ADR 0157): `started` when work is taken,
|
|
||||||
// `log.<build id>` for every line, `built` for the outcome. The log's tail token is the build's
|
|
||||||
// id, so a reader follows one build by subject alone.
|
|
||||||
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
||||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0121"},
|
Accepts: []string{"build"}, Emits: []string{"built"}, Decision: "novox/hq ADR 0121"},
|
||||||
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||||
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||||
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||||
@@ -117,9 +91,8 @@ var defaultSeats = []Seat{
|
|||||||
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
|
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
|
||||||
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
|
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
|
||||||
// any other name is a module's own to define and claim. Some of the mesh's own seats predate this
|
// any other name is a module's own to define and claim. Some of the mesh's own seats predate this
|
||||||
// convention and are not yet renamed (git, npm-package-registry, the-private-network) — those are
|
// convention and are not yet renamed (git, npm-package-registry, the-artifact-store,
|
||||||
// in the set, so they resolve by name, not by prefix. the-artifact-store was renamed on 2026-09-30
|
// the-private-network) — those are in the set, so they resolve by name, not by prefix.
|
||||||
// (novox/hq ADR 0156) and resolves through the alias table on a mesh that knew it.
|
|
||||||
func isSystemSeatName(name string) bool {
|
func isSystemSeatName(name string) bool {
|
||||||
return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-")
|
return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-")
|
||||||
}
|
}
|
||||||
@@ -296,22 +269,6 @@ func CanHold(m Manifest, seat Seat) error {
|
|||||||
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
||||||
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
|
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
|
||||||
}
|
}
|
||||||
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that
|
|
||||||
// does not answer what the role promises is every caller's timeout, found at registration and
|
|
||||||
// at handover instead, naming the verbs rather than the fact that something is missing.
|
|
||||||
if missing := unservedVerbs(claimed.ServesFor(m), seat.Serves); len(missing) > 0 {
|
|
||||||
return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+
|
|
||||||
"(novox/hq ADR 0132) — a holder names every verb its seat declares, under the claim's "+
|
|
||||||
"serves or among its own tools",
|
|
||||||
m.Module, seat.Name, strings.Join(missing, ", "))
|
|
||||||
}
|
|
||||||
// And nothing the seat does not promise: a verb named here that the protocol lacks is served
|
|
||||||
// to nobody, which is a typo the holder would otherwise discover as a caller's timeout.
|
|
||||||
if extra := unpromised(claimed.Serves, seat.Serves); len(extra) > 0 {
|
|
||||||
return fmt.Errorf("%s claims %s and says it serves %s, which that seat's protocol does not "+
|
|
||||||
"promise — a claim's serves names the seat's verbs and nothing else",
|
|
||||||
m.Module, seat.Name, strings.Join(extra, ", "))
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,8 @@ type SeatDeclaration struct {
|
|||||||
Accepts []string `json:"accepts,omitempty"`
|
Accepts []string `json:"accepts,omitempty"`
|
||||||
// Emits are the verbs the holder publishes: 1:many, nobody obliged to act.
|
// Emits are the verbs the holder publishes: 1:many, nobody obliged to act.
|
||||||
Emits []string `json:"emits,omitempty"`
|
Emits []string `json:"emits,omitempty"`
|
||||||
// Serves are the verbs the holder answers: request and reply, awaited. A bare name, or the
|
// Serves are the verbs the holder answers: request and reply, awaited.
|
||||||
// verb in full with its schema (novox/hq ADR 0132).
|
Serves []string `json:"serves,omitempty"`
|
||||||
Serves []Verb `json:"serves,omitempty"`
|
|
||||||
|
|
||||||
// RetainSeconds is how long the inbound backlog survives with no holder, zero for the
|
// RetainSeconds is how long the inbound backlog survives with no holder, zero for the
|
||||||
// mesh's default. Retention belongs to whoever owns the namespace (design 29 §3) — a seat
|
// mesh's default. Retention belongs to whoever owns the namespace (design 29 §3) — a seat
|
||||||
@@ -63,7 +62,7 @@ func (s SeatDeclaration) At() string {
|
|||||||
func (s SeatDeclaration) verbs() []string {
|
func (s SeatDeclaration) verbs() []string {
|
||||||
out := append([]string{}, s.Accepts...)
|
out := append([]string{}, s.Accepts...)
|
||||||
out = append(out, s.Emits...)
|
out = append(out, s.Emits...)
|
||||||
return append(out, VerbNames(s.Serves)...)
|
return append(out, s.Serves...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// declaredSeatProblems is what one manifest can be judged on alone.
|
// declaredSeatProblems is what one manifest can be judged on alone.
|
||||||
@@ -208,7 +207,7 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
}
|
}
|
||||||
// A holder that does not answer what the seat promises is a caller's timeout, found
|
// A holder that does not answer what the seat promises is a caller's timeout, found
|
||||||
// at assignment instead.
|
// at assignment instead.
|
||||||
if missing := unserved(m, c, s); len(missing) > 0 {
|
if missing := unserved(m, s); len(missing) > 0 {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s claims %s but does not serve %s, which that seat's protocol promises",
|
"%s claims %s but does not serve %s, which that seat's protocol promises",
|
||||||
module, c.Name, strings.Join(missing, ", ")))
|
module, c.Name, strings.Join(missing, ", ")))
|
||||||
@@ -221,16 +220,16 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
|
|
||||||
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
|
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
|
||||||
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
|
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
|
||||||
// is code the module either has or has not written — under the claim's serves, or among its own.
|
// is code the module either has or has not written.
|
||||||
func unserved(m Manifest, c Claim, s SeatDeclaration) []string {
|
func unserved(m Manifest, s SeatDeclaration) []string {
|
||||||
has := map[string]bool{}
|
has := map[string]bool{}
|
||||||
for _, t := range c.ServesFor(m) {
|
for _, t := range m.Tools {
|
||||||
has[t] = true
|
has[t] = true
|
||||||
}
|
}
|
||||||
var missing []string
|
var missing []string
|
||||||
for _, t := range s.Serves {
|
for _, t := range s.Serves {
|
||||||
if !has[t.Name] {
|
if !has[t] {
|
||||||
missing = append(missing, t.Name)
|
missing = append(missing, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return missing
|
return missing
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func problemsFor(t *testing.T, shelf Shelf) string {
|
|||||||
func telegram() Manifest {
|
func telegram() Manifest {
|
||||||
return Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []SeatDeclaration{{
|
return Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []SeatDeclaration{{
|
||||||
Name: "telegram-sender", Scope: ScopeMesh,
|
Name: "telegram-sender", Scope: ScopeMesh,
|
||||||
Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []Verb{{Name: "status"}},
|
Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []string{"status"},
|
||||||
}}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}}
|
}}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,22 +70,6 @@ func TestAHolderMustServeWhatItsSeatPromises(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The claim may say what it serves for the role instead, when the module's own tools are not the
|
|
||||||
// seat's verbs (ADR 0160).
|
|
||||||
func TestAClaimMayNameWhatItServesForTheSeat(t *testing.T) {
|
|
||||||
m := telegram()
|
|
||||||
m.Tools = []string{"telegram_send"}
|
|
||||||
m.Claims = append([]Claim(nil), m.Claims...)
|
|
||||||
for i := range m.Claims {
|
|
||||||
if m.Claims[i].Name == "telegram-sender" {
|
|
||||||
m.Claims[i].Serves = []string{"status"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := problemsFor(t, Shelf{"telegram": m}); strings.Contains(got, "does not serve") {
|
|
||||||
t.Fatalf("a claim naming the seat's verb was refused: %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A seat with no protocol is a marker: which module is this node's showcase, or its packet filter.
|
// A seat with no protocol is a marker: which module is this node's showcase, or its packet filter.
|
||||||
// Most node-scoped seats are markers, so refusing one would refuse the majority of the set.
|
// Most node-scoped seats are markers, so refusing one would refuse the majority of the set.
|
||||||
func TestASeatWithoutAProtocolIsAMarkerNotAMistake(t *testing.T) {
|
func TestASeatWithoutAProtocolIsAMarkerNotAMistake(t *testing.T) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
delivered[s.Delivers] = s.Name
|
delivered[s.Delivers] = s.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(Seats()) != 15 {
|
if len(Seats()) != 14 {
|
||||||
t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+
|
t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+
|
||||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,7 @@ func TestAPinStillWinsOverTheSeat(t *testing.T) {
|
|||||||
// A consumer coupled to one provider's contents has said so, and the seat does not overrule it.
|
// A consumer coupled to one provider's contents has said so, and the seat does not overrule it.
|
||||||
got, err := Resolve(registryShelf(), []string{"builder"}, reachable(),
|
got, err := Resolve(registryShelf(), []string{"builder"}, reachable(),
|
||||||
World{Offered: twoRegistries(), Held: giteaHoldsTheSeat(),
|
World{Offered: twoRegistries(), Held: giteaHoldsTheSeat(),
|
||||||
Pinned: map[string]Chosen{"npm-package-registry": {Node: "archive", Module: "verdaccio"}}})
|
Pinned: map[string]string{"npm-package-registry": "archive"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func aModuleWithAnEnvFileSecret(exception string) Manifest {
|
|||||||
}
|
}
|
||||||
return Manifest{
|
return Manifest{
|
||||||
Module: "app", Version: "1",
|
Module: "app", Version: "1",
|
||||||
OwnSecrets: OwnSecrets{"token": {Path: "/var/lib/app/token.secret"}},
|
OwnSecrets: map[string]string{"token": "/var/lib/app/token.secret"},
|
||||||
Resources: []map[string]any{
|
Resources: []map[string]any{
|
||||||
{"id": "env", "type": "file", "path": "/var/lib/app/server.env", "mode": "0600",
|
{"id": "env", "type": "file", "path": "/var/lib/app/server.env", "mode": "0600",
|
||||||
"content": "APP_TOKEN=${secret:token}\n"},
|
"content": "APP_TOKEN=${secret:token}\n"},
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func fileNamed(out []map[string]any, id string) map[string]any {
|
|||||||
func TestAFileGetsTheSecretItsContentAsksFor(t *testing.T) {
|
func TestAFileGetsTheSecretItsContentAsksFor(t *testing.T) {
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
||||||
Module: "gitea",
|
Module: "gitea",
|
||||||
OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
|
OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"},
|
||||||
Resources: []map[string]any{{
|
Resources: []map[string]any{{
|
||||||
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
||||||
"content": "[security]\nSECRET_KEY = ${secret:admin}\n",
|
"content": "[security]\nSECRET_KEY = ${secret:admin}\n",
|
||||||
@@ -130,7 +130,7 @@ func TestTwoConsumersOfOneProvisionEachGetTheirOwnInAPlaceholderFile(t *testing.
|
|||||||
func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) {
|
func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) {
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
||||||
Module: "gitea",
|
Module: "gitea",
|
||||||
OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
|
OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"},
|
||||||
Resources: []map[string]any{{
|
Resources: []map[string]any{{
|
||||||
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
||||||
"content": "SECRET_KEY = ${secret:adnim}\n",
|
"content": "SECRET_KEY = ${secret:adnim}\n",
|
||||||
@@ -151,7 +151,7 @@ func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) {
|
|||||||
// One module may not read another's credential by guessing its name.
|
// One module may not read another's credential by guessing its name.
|
||||||
func TestAFileCannotNameAnotherModulesSecret(t *testing.T) {
|
func TestAFileCannotNameAnotherModulesSecret(t *testing.T) {
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{
|
r := Resolution{Node: "anchor", Modules: []Manifest{
|
||||||
{Module: "postgres", OwnSecrets: OwnSecrets{"superuser": {Path: "/var/lib/postgres/su.env"}}},
|
{Module: "postgres", OwnSecrets: map[string]string{"superuser": "/var/lib/postgres/su.env"}},
|
||||||
{Module: "gitea", Resources: []map[string]any{{
|
{Module: "gitea", Resources: []map[string]any{{
|
||||||
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
||||||
"content": "PASSWORD=${secret:superuser}\n",
|
"content": "PASSWORD=${secret:superuser}\n",
|
||||||
@@ -174,7 +174,7 @@ func TestAFileCannotNameAnotherModulesSecret(t *testing.T) {
|
|||||||
func TestASettingThatCarriesAPlaceholderIsStillFilled(t *testing.T) {
|
func TestASettingThatCarriesAPlaceholderIsStillFilled(t *testing.T) {
|
||||||
r := Resolution{Node: "workstation", Modules: []Manifest{{
|
r := Resolution{Node: "workstation", Modules: []Manifest{{
|
||||||
Module: "chat",
|
Module: "chat",
|
||||||
OwnSecrets: OwnSecrets{"api-token": {Path: "/home/operator/.config/chat/token"}},
|
OwnSecrets: map[string]string{"api-token": "/home/operator/.config/chat/token"},
|
||||||
Resources: []map[string]any{{
|
Resources: []map[string]any{{
|
||||||
"id": "settings", "type": "file", "merge": "json",
|
"id": "settings", "type": "file", "merge": "json",
|
||||||
"path": "/home/operator/.config/chat/settings.json", "content": "{}",
|
"path": "/home/operator/.config/chat/settings.json", "content": "{}",
|
||||||
@@ -207,7 +207,7 @@ func TestANameMeaningTwoThingsIsRefused(t *testing.T) {
|
|||||||
Node: "anchor",
|
Node: "anchor",
|
||||||
Modules: []Manifest{{
|
Modules: []Manifest{{
|
||||||
Module: "thing",
|
Module: "thing",
|
||||||
OwnSecrets: OwnSecrets{"store": {Path: "/var/lib/thing/own.env"}},
|
OwnSecrets: map[string]string{"store": "/var/lib/thing/own.env"},
|
||||||
Secrets: map[string]string{"store": "/var/lib/thing/granted.env"},
|
Secrets: map[string]string{"store": "/var/lib/thing/granted.env"},
|
||||||
}},
|
}},
|
||||||
Needs: []Needed{{Name: "store", From: "anchor", Sealed: "sealed-granted"}},
|
Needs: []Needed{{Name: "store", From: "anchor", Sealed: "sealed-granted"}},
|
||||||
@@ -225,7 +225,7 @@ func TestANameMeaningTwoThingsIsRefused(t *testing.T) {
|
|||||||
func TestAFileWithNoPlaceholderIsLeftAlone(t *testing.T) {
|
func TestAFileWithNoPlaceholderIsLeftAlone(t *testing.T) {
|
||||||
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
||||||
Module: "gitea",
|
Module: "gitea",
|
||||||
OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
|
OwnSecrets: map[string]string{"admin": "/var/lib/gitea/admin.env"},
|
||||||
Resources: []map[string]any{{
|
Resources: []map[string]any{{
|
||||||
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "content": "RUN_MODE=prod\n",
|
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "content": "RUN_MODE=prod\n",
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// An operator's value, where a definition needs one (novox/hq ADR 0112, ADR 0155, design 27).
|
|
||||||
//
|
|
||||||
// A mail server's domain, a site's name, the address a proxy forwards from: values that are true of
|
|
||||||
// one installation and of no other, and that a module's software must be told. They had nowhere to
|
|
||||||
// live but the definition, which is how a catalogue meant for any mesh came to name this one
|
|
||||||
// (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the
|
|
||||||
// operator answering.
|
|
||||||
//
|
|
||||||
// `${setting:<key>}` in a file's content is filled from the module's settings layers — the mesh's,
|
|
||||||
// then this node's — the same layers a mergeable JSON file and a contribution already take, so
|
|
||||||
// `settings set <module>` is the one place a person's values go. **Refused when no layer sets it**,
|
|
||||||
// naming the key and the remedy: a definition that carried a default for a mail domain would be
|
|
||||||
// carrying the very literal this removes, and a blank written silently would be a service that
|
|
||||||
// comes up wrong somewhere that names neither the module nor the key.
|
|
||||||
|
|
||||||
// settingRef is how a definition asks for an operator's value: ${setting:<key>}.
|
|
||||||
var settingRef = regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`)
|
|
||||||
|
|
||||||
// settingsUsed is every key a file's content asks for, once each, in order of first use.
|
|
||||||
func settingsUsed(content string) []string {
|
|
||||||
var keys []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range settingRef.FindAllStringSubmatch(content, -1) {
|
|
||||||
if !seen[m[1]] {
|
|
||||||
seen[m[1]] = true
|
|
||||||
keys = append(keys, m[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingInto fills a file's ${setting:…} placeholders from the layers over a module.
|
|
||||||
//
|
|
||||||
// The last layer setting a key wins, which is the node's over the mesh's — the same order settle
|
|
||||||
// applies to a mergeable file. A value that is not a string is written the way a program would read
|
|
||||||
// it (a number without a trailing .000000, a boolean as true/false).
|
|
||||||
func settingInto(resource map[string]any, layers []Layer, module string) error {
|
|
||||||
if fmt.Sprint(resource["type"]) != "file" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content, ok := resource["content"].(string)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, key := range settingsUsed(content) {
|
|
||||||
value, set := settingValue(layers, key)
|
|
||||||
if !set {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+
|
|
||||||
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
|
|
||||||
"`settings set %s <file>` with {%q: …}%s",
|
|
||||||
module, key, key, module, key, orNoSettings(layers))
|
|
||||||
}
|
|
||||||
content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value))
|
|
||||||
}
|
|
||||||
resource["content"] = content
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func settingValue(layers []Layer, key string) (any, bool) {
|
|
||||||
var value any
|
|
||||||
set := false
|
|
||||||
for _, layer := range layers {
|
|
||||||
if v, has := layer.Values[key]; has {
|
|
||||||
value, set = v, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value, set
|
|
||||||
}
|
|
||||||
|
|
||||||
func orNoSettings(layers []Layer) string {
|
|
||||||
var keys []string
|
|
||||||
for _, l := range layers {
|
|
||||||
for k := range l.Values {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(keys) == 0 {
|
|
||||||
return "; no setting is set for this module"
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
return "; set today: " + strings.Join(keys, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// settingKeysUsedBy is every key a module's files, contributions and served facts ask for, so a
|
|
||||||
// setting that lands in one is not called stray.
|
|
||||||
func settingKeysUsedBy(m Manifest) map[string]bool {
|
|
||||||
used := map[string]bool{}
|
|
||||||
note := func(s string) {
|
|
||||||
for _, k := range settingsUsed(s) {
|
|
||||||
used[k] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) != "file" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if content, ok := r["content"].(string); ok {
|
|
||||||
note(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inValues := func(values map[string]any) {
|
|
||||||
for _, v := range values {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
note(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, values := range m.Contributes {
|
|
||||||
inValues(values)
|
|
||||||
}
|
|
||||||
for _, locals := range m.ContributesMany {
|
|
||||||
for _, values := range locals {
|
|
||||||
inValues(values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, values := range m.Serves {
|
|
||||||
inValues(values)
|
|
||||||
}
|
|
||||||
return used
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// An operator's value reaches a file from the assignment's settings, the node's layer over the
|
|
||||||
// mesh's (novox/hq ADR 0112, ADR 0155), and a value nothing set is refused by name.
|
|
||||||
func TestASettingReachesAFileFromTheLayers(t *testing.T) {
|
|
||||||
file := map[string]any{"id": "env", "type": "file", "path": "/x/mail.env",
|
|
||||||
"content": "DOMAIN=${setting:domain}\nSITENAME=${setting:sitename}\nWORKERS=${setting:workers}\n"}
|
|
||||||
layers := []Layer{
|
|
||||||
{From: "the mesh", Values: map[string]any{"domain": "example.tld", "sitename": "Mesh", "workers": float64(4)}},
|
|
||||||
{From: "this node", Values: map[string]any{"sitename": "This one"}},
|
|
||||||
}
|
|
||||||
if err := settingInto(file, layers, "mail"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if file["content"] != "DOMAIN=example.tld\nSITENAME=This one\nWORKERS=4\n" {
|
|
||||||
t.Fatalf("filled as %q", file["content"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestASettingNothingSetIsRefusedByName(t *testing.T) {
|
|
||||||
file := map[string]any{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"}
|
|
||||||
err := settingInto(file, []Layer{{From: "the mesh", Values: map[string]any{"other": "x"}}}, "mail")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a setting nothing set was written as something")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"${setting:domain}", "settings set mail", "set today: other"} {
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Fatalf("the refusal lacks %q: %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Left as it was: the literal placeholder must never reach a machine.
|
|
||||||
if file["content"] != "DOMAIN=${setting:domain}\n" {
|
|
||||||
t.Fatalf("content was changed on refusal: %q", file["content"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A key a file asks for is a destination, so setting it is not called stray.
|
|
||||||
func TestASettingAFileAsksForIsNotStray(t *testing.T) {
|
|
||||||
m := Manifest{Module: "mail", Resources: []map[string]any{
|
|
||||||
{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"},
|
|
||||||
}}
|
|
||||||
layers := []Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld", "stray": "x"}}}
|
|
||||||
unused := strings.Join(UnusedSettings(m, layers), "; ")
|
|
||||||
if strings.Contains(unused, `"domain"`) {
|
|
||||||
t.Fatalf("a key a file asks for was called stray: %s", unused)
|
|
||||||
}
|
|
||||||
if !strings.Contains(unused, `"stray"`) {
|
|
||||||
t.Fatalf("a key nothing reads was not named: %s", unused)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -85,67 +85,17 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settle lays settings over what a provider serves. Exported because a served fact is settled where
|
// Settle lays settings over a module's own values. Exported for what a provider serves, which is
|
||||||
// the mesh is walked rather than where a node is declared.
|
// settled where the mesh is walked rather than where a node is declared.
|
||||||
//
|
|
||||||
// **A setting overrides a served key; it never adds one** (novox/hq 04-ISSUES/173). What a consumer
|
|
||||||
// is told is the provider's contract, and a setting made for one of the provider's files — a site
|
|
||||||
// name, a public address — is not part of it. Before this, every setting of a module reached every
|
|
||||||
// consumer of every provision it served.
|
|
||||||
func Settle(base map[string]any, layers []Layer) (map[string]any, error) {
|
func Settle(base map[string]any, layers []Layer) (map[string]any, error) {
|
||||||
return overridden(base, layers, "what is served")
|
return settle(base, layers, nil, "what is served")
|
||||||
}
|
|
||||||
|
|
||||||
// overridden lays settings over a map whose keys are its contract: a contribution, a served fact.
|
|
||||||
// Only the keys the map already declares are touched; the rest of a layer is somebody else's
|
|
||||||
// business (a file's, another destination's) and is left to reach it there.
|
|
||||||
//
|
|
||||||
// A declared value may itself be the operator's, `${setting:<key>}` (ADR 0155): a mail provider
|
|
||||||
// serves its domain, an identity provider its issuer, and neither is the definition's to state.
|
|
||||||
// Filled from the layers after the overrides, and refused by name when nothing sets it — a literal
|
|
||||||
// placeholder handed to a consumer is a service configured against a string nobody meant.
|
|
||||||
func overridden(base map[string]any, layers []Layer, what string) (map[string]any, error) {
|
|
||||||
kept := make([]Layer, 0, len(layers))
|
|
||||||
for _, layer := range layers {
|
|
||||||
values := map[string]any{}
|
|
||||||
for key, value := range layer.Values {
|
|
||||||
if _, declared := base[key]; declared {
|
|
||||||
values[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kept = append(kept, Layer{From: layer.From, Values: values})
|
|
||||||
}
|
|
||||||
merged, err := settle(base, kept, nil, what)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for key, value := range merged {
|
|
||||||
s, ok := value.(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, asked := range settingsUsed(s) {
|
|
||||||
v, set := settingValue(layers, asked)
|
|
||||||
if !set {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s says ${setting:%s} for %q, and nothing sets %q — an operator's value is the "+
|
|
||||||
"assignment's, never the definition's (novox/hq ADR 0112)%s",
|
|
||||||
what, asked, key, asked, orNoSettings(layers))
|
|
||||||
}
|
|
||||||
s = strings.ReplaceAll(s, "${setting:"+asked+"}", plainly(v))
|
|
||||||
}
|
|
||||||
merged[key] = s
|
|
||||||
}
|
|
||||||
return merged, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// settle lays the layers over a module's own values, in order.
|
// settle lays the layers over a module's own values, in order.
|
||||||
//
|
//
|
||||||
// Shared by a file's content and a module's contributions, because they are the same act: the
|
// Shared by a file's content and a module's contributions, because they are the same act: the
|
||||||
// module says what it means by default, and somebody says what it means here. A contribution that
|
// module says what it means by default, and somebody says what it means here. A contribution that
|
||||||
// could not be settled would have to be edited to be reused anywhere else. The two differ in one
|
// could not be settled would have to be edited to be reused anywhere else.
|
||||||
// respect, and the caller decides it: a file takes keys it did not declare (a setting may add to a
|
|
||||||
// configuration), a contribution or served fact does not (overridden).
|
|
||||||
func settle(base map[string]any, layers []Layer, protected map[string]bool, what string) (
|
func settle(base map[string]any, layers []Layer, protected map[string]bool, what string) (
|
||||||
map[string]any, error) {
|
map[string]any, error) {
|
||||||
merged := deepCopy(base)
|
merged := deepCopy(base)
|
||||||
@@ -199,22 +149,23 @@ func deepCopy(in map[string]any) map[string]any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnusedSettings names settings that reach nothing.
|
// UnusedSettings names settings that reached no file.
|
||||||
//
|
//
|
||||||
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
|
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
|
||||||
// nothing — and would find out by the machine not behaving differently, which is the slowest
|
// nothing — and would find out by the machine not behaving differently, which is the slowest
|
||||||
// way there is. This is what makes that visible at the moment they set it.
|
// way there is. This is what makes that visible at the moment they set it.
|
||||||
//
|
|
||||||
// Where a key can land: any mergeable file takes any key; a file asking for `${setting:<key>}`
|
|
||||||
// takes that key (ADR 0155); a contribution or a served fact takes a key it declares, and no other
|
|
||||||
// (novox/hq 04-ISSUES/173); and the mesh's own words — `expose`, `ports`, `reach`, `endpoints` —
|
|
||||||
// are read by the mesh. A key none of those takes is stray, and is said so rather than dropped.
|
|
||||||
func UnusedSettings(m Manifest, layers []Layer) []string {
|
func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
if how, _ := r["merge"].(string); how != "" {
|
if how, _ := r["merge"].(string); how != "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A contribution is a destination too. A route's hostname is exactly the kind of thing that
|
||||||
|
// differs between one mesh and the next, and calling it stray would refuse the one setting
|
||||||
|
// most modules that publish anything will have.
|
||||||
|
if len(m.Contributes) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// A computed module has no resources here to look at — they are worked out per node, and
|
// A computed module has no resources here to look at — they are worked out per node, and
|
||||||
// whether a setting lands is not knowable until then. Silence rather than a wrong answer:
|
// whether a setting lands is not knowable until then. Silence rather than a wrong answer:
|
||||||
// claiming every setting on the private network is stray would be worse than saying nothing.
|
// claiming every setting on the private network is stray would be worse than saying nothing.
|
||||||
@@ -222,30 +173,9 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
lands := settingKeysUsedBy(m)
|
|
||||||
for _, values := range m.Contributes {
|
|
||||||
for key := range values {
|
|
||||||
lands[key] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, locals := range m.ContributesMany {
|
|
||||||
for _, values := range locals {
|
|
||||||
for key := range values {
|
|
||||||
lands[key] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, served := range m.Serves {
|
|
||||||
for key := range served {
|
|
||||||
lands[key] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var unused []string
|
var unused []string
|
||||||
for _, layer := range layers {
|
for _, layer := range layers {
|
||||||
for key := range layer.Values {
|
for key := range layer.Values {
|
||||||
if lands[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `expose` is a real destination for a module that listens: it overrides a port's
|
// `expose` is a real destination for a module that listens: it overrides a port's
|
||||||
// source (novox/hq ADR 0046), validated in Exposure, so it is not stray here.
|
// source (novox/hq ADR 0046), validated in Exposure, so it is not stray here.
|
||||||
if key == ExposeSetting && len(m.Listens) > 0 {
|
if key == ExposeSetting && len(m.Listens) > 0 {
|
||||||
@@ -267,18 +197,9 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
|||||||
if key == EndpointsSetting && len(m.Listens) > 0 {
|
if key == EndpointsSetting && len(m.Listens) > 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// `places` puts a declared directory where this machine keeps it, `accesses` says where
|
|
||||||
// the operator's data is (novox/hq issue 153). Validated in Places and AccessPlaces.
|
|
||||||
if key == PlacesSetting && len(directoriesOf(m)) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if key == AccessesSetting && len(m.Accesses) > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
unused = append(unused, fmt.Sprintf(
|
unused = append(unused, fmt.Sprintf(
|
||||||
"%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
|
"%s sets %q, and %s has no file or contribution to merge it into",
|
||||||
"declares no %q in what it contributes or serves",
|
layer.From, key, m.Module))
|
||||||
layer.From, key, m.Module, key, key))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(unused)
|
sort.Strings(unused)
|
||||||
|
|||||||
@@ -167,45 +167,11 @@ func TestSettingsThatReachNothingAreNamed(t *testing.T) {
|
|||||||
{"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"},
|
{"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"},
|
||||||
}}
|
}}
|
||||||
unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}})
|
unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}})
|
||||||
if len(unused) != 1 || !strings.Contains(unused[0], "no file that merges it") {
|
if len(unused) != 1 || !strings.Contains(unused[0], "no file or contribution to merge it into") {
|
||||||
t.Errorf("settings that reached nothing were not named: %v", unused)
|
t.Errorf("settings that reached nothing were not named: %v", unused)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestASettingLandsOnlyWhereSomethingDeclaresIt(t *testing.T) {
|
|
||||||
// novox/hq 04-ISSUES/173. A module that contributes a route and serves a provision takes a
|
|
||||||
// setting for a key either declares, and a setting for a key neither declares is stray — it
|
|
||||||
// would not reach the route or the served fact, so it must be said rather than dropped.
|
|
||||||
m := Manifest{Module: "mail",
|
|
||||||
Contributes: map[string]map[string]any{"reverse-proxy": {"host": "mail", "port": 8080}},
|
|
||||||
Serves: map[string]map[string]any{"smtp": {"host": "mail", "port": 25}},
|
|
||||||
Resources: []map[string]any{
|
|
||||||
{"id": "env", "type": "file", "path": "/etc/mail.env", "content": "SITE=${setting:sitename}\n"},
|
|
||||||
}}
|
|
||||||
layers := []Layer{{From: "the mesh", Values: map[string]any{
|
|
||||||
"host": "post", "sitename": "Mail", "website": "https://www.example.tld"}}}
|
|
||||||
unused := UnusedSettings(m, layers)
|
|
||||||
if len(unused) != 1 || !strings.Contains(unused[0], `"website"`) {
|
|
||||||
t.Errorf("only website reaches nothing; named: %v", unused)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestASettingOverridesAServedKeyAndAddsNone(t *testing.T) {
|
|
||||||
// What a consumer is told is the provider's contract. A setting made for one of the
|
|
||||||
// provider's files — its site name, its public address — is not part of it.
|
|
||||||
served, err := Settle(map[string]any{"host": "mail", "port": 25},
|
|
||||||
[]Layer{{From: "the mesh", Values: map[string]any{"host": "post", "sitename": "Mail"}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if served["host"] != "post" {
|
|
||||||
t.Errorf("the setting did not override the served host: %v", served)
|
|
||||||
}
|
|
||||||
if _, leaked := served["sitename"]; leaked {
|
|
||||||
t.Errorf("a setting for a file reached the consumers: %v", served)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) {
|
func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) {
|
||||||
// Rather than on the machine, at apply time, as a file the program cannot read.
|
// Rather than on the machine, at apply time, as a file the program cannot read.
|
||||||
_, err := ApplySettings(file(`this is not json`), nil)
|
_, err := ApplySettings(file(`this is not json`), nil)
|
||||||
@@ -213,24 +179,3 @@ func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) {
|
|||||||
t.Fatal("a module claiming to merge as JSON shipped something else and was accepted")
|
t.Fatal("a module claiming to merge as JSON shipped something else and was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAServedValueMayBeTheOperators(t *testing.T) {
|
|
||||||
// A mail provider serves its domain and an identity provider its issuer; neither is the
|
|
||||||
// definition's to state (ADR 0155). Filled from the layers, refused by name when unset.
|
|
||||||
served, err := Settle(map[string]any{"port": 587, "domain": "${setting:domain}"},
|
|
||||||
[]Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld"}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if served["domain"] != "example.tld" {
|
|
||||||
t.Errorf("the operator's value did not fill the served key: %v", served)
|
|
||||||
}
|
|
||||||
_, err = Settle(map[string]any{"domain": "${setting:domain}"}, nil)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), `"domain"`) {
|
|
||||||
t.Errorf("a served value nothing sets must be refused by name; got %v", err)
|
|
||||||
}
|
|
||||||
m := Manifest{Module: "mail", Serves: map[string]map[string]any{"smtp": {"domain": "${setting:domain}"}}}
|
|
||||||
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"domain": "x"}}}); len(unused) != 0 {
|
|
||||||
t.Errorf("a setting a served fact asks for is not stray: %v", unused)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A provider with one credential shares it (novox/hq ADR 0158): the offer names the own secret, the
|
|
||||||
// secret says how it is taken, and a consumer's need carries the name so the plan mints its copy
|
|
||||||
// from the provider's value.
|
|
||||||
func TestAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) {
|
|
||||||
m, err := ParseManifest([]byte(`{"module":"downloader","version":"1",
|
|
||||||
"own-secrets":{"password":{"path":"/var/lib/mesh/downloader/password","taken":"at-start"}},
|
|
||||||
"provides":[{"name":"downloader-api","credential":{"own":"password"}}],
|
|
||||||
"serves":{"downloader-api":{"port":8080,"username":"admin"}}}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if own, shared := m.SharedCredentialOf("downloader-api"); !shared || own != "password" {
|
|
||||||
t.Fatalf("the offer's credential was not read: %v %v", own, shared)
|
|
||||||
}
|
|
||||||
if got := m.ProvisionsSharing("password"); len(got) != 1 || got[0] != "downloader-api" {
|
|
||||||
t.Fatalf("the provisions sharing the secret: %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
for want, raw := range map[string]string{
|
|
||||||
"declares no such secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":"password"}}]}`,
|
|
||||||
"must say how the module takes it": `{"module":"d","version":"1","own-secrets":{"password":"/p"},
|
|
||||||
"provides":[{"name":"d-api","credential":{"own":"password"}}]}`,
|
|
||||||
"names no own secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":""}}]}`,
|
|
||||||
} {
|
|
||||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("expected a refusal saying %q, got %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sharingShelf() map[string]Manifest {
|
|
||||||
return shelf(
|
|
||||||
Manifest{Module: "downloader", Version: "1",
|
|
||||||
Provides: []Offer{{Name: "downloader-api", Scope: ScopeMesh, Credential: &OfferCredential{Own: "password"}}},
|
|
||||||
OwnSecrets: OwnSecrets{"password": {Path: "/var/lib/mesh/downloader/password", Taken: TakenAtStart}},
|
|
||||||
Serves: map[string]map[string]any{"downloader-api": {"port": 8080, "username": "admin"}}},
|
|
||||||
Manifest{Module: "manager", Version: "1", Requires: []string{"downloader-api"}},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAConsumersNeedCarriesTheSharedSecretsName(t *testing.T) {
|
|
||||||
// On the same machine.
|
|
||||||
together, err := Resolve(sharingShelf(), []string{"downloader", "manager"}, workstation(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, n := range together.Needs {
|
|
||||||
if n.Name == "downloader-api" && n.For == "manager" {
|
|
||||||
found = true
|
|
||||||
if n.SharedOwn != "password" {
|
|
||||||
t.Fatalf("the need on one machine does not name the shared secret: %+v", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatalf("the manager's need was not resolved: %+v", together.Needs)
|
|
||||||
}
|
|
||||||
// Across machines, the provider known by its module.
|
|
||||||
apart, err := Resolve(sharingShelf(), []string{"manager"}, onBoth("example.tld"), World{
|
|
||||||
Offered: map[string][]Provider{"downloader-api": {{Node: "home-server", At: "home-server.internal",
|
|
||||||
Module: "downloader", Serves: map[string]any{"port": 8080}}}},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, n := range apart.Needs {
|
|
||||||
if n.Name == "downloader-api" && n.SharedOwn != "password" {
|
|
||||||
t.Fatalf("the need across machines does not name the shared secret: %+v", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A short-form port may name its protocol — "3478/udp" — and the mesh assigns the number exactly
|
|
||||||
// as it does for "3478": the protocol rides along on the outside. Read as one token, the suffix made
|
|
||||||
// the entry "not a port" and the runtime published it on a random machine port (found on ace: unifi's
|
|
||||||
// STUN and discovery, while every TCP pin beside them held).
|
|
||||||
func TestAShortFormPortKeepsItsProtocolAndGetsItsMachinePort(t *testing.T) {
|
|
||||||
container := map[string]any{"type": "container", "ports": []any{"3478/udp", "8443", "10001/udp"}}
|
|
||||||
with := Rendering{
|
|
||||||
Given: map[string]map[int]int{"unifi": {3478: 3478}},
|
|
||||||
Ports: map[string]map[int]int{"unifi": {8443: 20010, 10001: 20011}},
|
|
||||||
}
|
|
||||||
publishedOn(container, "unifi", with)
|
|
||||||
got := fmt.Sprint(container["ports"])
|
|
||||||
want := "[3478:3478/udp 20010:8443 20011:10001/udp]"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("published %s, want %s", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// The vault's provision is one the controller itself dereferences — every minted credential is
|
|
||||||
// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second
|
|
||||||
// claimant refused by name rather than a candidate for a pin (novox/hq ADR 0161, issue 106).
|
|
||||||
func TestTheVaultsSeatDeliversSecret(t *testing.T) {
|
|
||||||
seat, known := SeatNamed("mesh-vault")
|
|
||||||
if !known {
|
|
||||||
t.Fatal("mesh-vault is not in the mesh's own set")
|
|
||||||
}
|
|
||||||
if seat.Scope != ScopeMesh || seat.Delivers != "secret" {
|
|
||||||
t.Fatalf("mesh-vault is %s-scoped and delivers %q; one per mesh, delivering secret", seat.Scope, seat.Delivers)
|
|
||||||
}
|
|
||||||
vault := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
|
||||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh}}}
|
|
||||||
if err := CanHold(vault, seat); err != nil {
|
|
||||||
t.Fatalf("the vault, claiming its seat and providing secret, was refused: %v", err)
|
|
||||||
}
|
|
||||||
another := Manifest{Module: "other-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}}
|
|
||||||
if err := CanHold(another, seat); err == nil {
|
|
||||||
t.Fatal("a provider of secret that does not claim the seat was allowed to hold it")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
|
||||||
// its answer (novox/hq ADR 0132, design 33 §2).
|
|
||||||
//
|
|
||||||
// **A name alone is not callable by something that has never seen the mesh before**, which is the
|
|
||||||
// whole population a tool surface exists for. So a seat's protocol carries the definition, in the
|
|
||||||
// form an agent protocol already uses — a JSON schema for the input — so nothing translates between
|
|
||||||
// a seat's idea of an argument and the caller's.
|
|
||||||
//
|
|
||||||
// A manifest may still write a bare verb name (`"serves": ["price"]`); that is a Verb with only a
|
|
||||||
// name, and the module's runtime answers `tools` with the rest. The two forms read into one type so
|
|
||||||
// nothing downstream cares which was written.
|
|
||||||
type Verb struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Input map[string]any `json:"input,omitempty"`
|
|
||||||
Output map[string]any `json:"output,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Verb) UnmarshalJSON(raw []byte) error {
|
|
||||||
trimmed := bytes.TrimSpace(raw)
|
|
||||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
|
||||||
var name string
|
|
||||||
if err := json.Unmarshal(trimmed, &name); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*v = Verb{Name: name}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Strictly, like the manifest around it: a misspelt key in a tool's definition would otherwise
|
|
||||||
// describe a tool nobody can call and refuse nothing.
|
|
||||||
type plain Verb
|
|
||||||
var p plain
|
|
||||||
decoder := json.NewDecoder(bytes.NewReader(trimmed))
|
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
if err := decoder.Decode(&p); err != nil {
|
|
||||||
return fmt.Errorf("a served verb is a name or {name, description, input, output}: %w", err)
|
|
||||||
}
|
|
||||||
if p.Name == "" {
|
|
||||||
return fmt.Errorf("a served verb has no name: %s", trimmed)
|
|
||||||
}
|
|
||||||
*v = Verb(p)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerbNames are the names alone, for the grants and the checks that care about nothing else.
|
|
||||||
func VerbNames(verbs []Verb) []string {
|
|
||||||
out := make([]string, 0, len(verbs))
|
|
||||||
for _, v := range verbs {
|
|
||||||
out = append(out, v.Name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ControllerSeatName is the seat the control plane holds, whose tools are the mesh's own verbs.
|
|
||||||
const ControllerSeatName = "mesh-controller"
|
|
||||||
|
|
||||||
// ControllerVerbs are the mesh's own verbs, as the `mesh-controller` seat's tools (novox/hq ADR 0154).
|
|
||||||
//
|
|
||||||
// **The same function the command line calls, and nothing the tool adds** (ADR 0035): each of these
|
|
||||||
// is a command the controller's binary already answers, run by the holder of the seat with the
|
|
||||||
// arguments below and answered with what the command printed. A verb here is a contract every future
|
|
||||||
// holder must implement, which is why the list is short and made of what an operator asks weekly.
|
|
||||||
// Additive within a version (design 33 §7); a verb that would break a caller takes a new version.
|
|
||||||
var ControllerVerbs = []Verb{
|
|
||||||
{Name: "tools", Description: "Every seat's tools, from the mesh's own records: what each role " +
|
|
||||||
"answers, whether or not its holder is up. The mesh's own verbs are the mesh-controller seat's.",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "status", Description: "What is wrong, what is quiet, what is out of date, and which " +
|
|
||||||
"machines are behind what the mesh would send them.",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
|
||||||
{Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " +
|
|
||||||
"and which machines run it.",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "seats", Description: "Every seat the mesh defines, what it delivers, and who holds it.",
|
|
||||||
Input: schema(nil, nil)},
|
|
||||||
{Name: "builds", Description: "What has been built lately and what came of it, for every module or for one; " +
|
|
||||||
"or, given a build's id, everything the build machine said while building it, line by line, from the bus.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"module": "one module's name; every module when absent",
|
|
||||||
"log": "a build's id (as `builds` lists it): print what the build machine said, line by line",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
|
|
||||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
|
||||||
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
|
|
||||||
"repository": "owner/repository: the plan a merge there would produce, saving nothing (what-if); with paths or modules",
|
|
||||||
"paths": "with repository: the files the merge would change, comma-separated, from the repository's root",
|
|
||||||
"modules": "with repository: or the modules it would change, comma-separated",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
|
||||||
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
|
||||||
{Name: "unassign", Description: "Take a module off a machine.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
|
||||||
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
|
|
||||||
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine's name",
|
|
||||||
"provision": "the provision, as the consumer requires it",
|
|
||||||
"from": "the node the chosen provider runs on",
|
|
||||||
"module": "the module providing it there",
|
|
||||||
}, []string{"node", "provision", "from", "module"})},
|
|
||||||
{Name: "unpin", Description: "Take that choice back, putting the question to the mesh again.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
|
|
||||||
{Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)},
|
|
||||||
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine, " +
|
|
||||||
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
|
|
||||||
"name: made anew and the machine sent, so the module starts again on it — only for a secret its " +
|
|
||||||
"definition says it reads at start; a value given to the mesh, or one the module applies to a backend, is refused with the reason.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"provision": "a pair credential: the provision whose credential to replace",
|
|
||||||
"consumer": "with provision: only the holder on this machine (optional)",
|
|
||||||
"node": "an own secret: the machine",
|
|
||||||
"module": "an own secret: the module",
|
|
||||||
"secret": "an own secret: its name in the module's definition",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
|
|
||||||
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"repository": "the repository's URL, or its path on the forge holding the git seat (owner/name)",
|
|
||||||
"path": "the module's directory inside it (optional)",
|
|
||||||
"ref": "the branch, tag or commit to build (optional)",
|
|
||||||
}, []string{"repository"})},
|
|
||||||
}
|
|
||||||
|
|
||||||
// schema is a JSON schema for an object of string properties, which is every argument the verbs
|
|
||||||
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call.
|
|
||||||
func schema(properties map[string]string, required []string) map[string]any {
|
|
||||||
props := map[string]any{}
|
|
||||||
for name, description := range properties {
|
|
||||||
props[name] = map[string]any{"type": "string", "description": description}
|
|
||||||
}
|
|
||||||
out := map[string]any{"type": "object", "properties": props}
|
|
||||||
if len(required) > 0 {
|
|
||||||
out["required"] = required
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// unpromised is what a claim says it serves and the seat's protocol never promised.
|
|
||||||
func unpromised(serves []string, promised []Verb) []string {
|
|
||||||
has := map[string]bool{}
|
|
||||||
for _, v := range promised {
|
|
||||||
has[v.Name] = true
|
|
||||||
}
|
|
||||||
var extra []string
|
|
||||||
for _, s := range serves {
|
|
||||||
if !has[s] {
|
|
||||||
extra = append(extra, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return extra
|
|
||||||
}
|
|
||||||
|
|
||||||
// unservedVerbs is what a seat promises and a claimant's offer for it does not answer.
|
|
||||||
func unservedVerbs(tools []string, promised []Verb) []string {
|
|
||||||
has := map[string]bool{}
|
|
||||||
for _, t := range tools {
|
|
||||||
has[t] = true
|
|
||||||
}
|
|
||||||
var missing []string
|
|
||||||
for _, v := range promised {
|
|
||||||
if !has[v.Name] {
|
|
||||||
missing = append(missing, v.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return missing
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user