Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b39e95361 | ||
|
|
7e4a0ecf9a | ||
|
|
7661f57833 | ||
|
|
076e0ae259 | ||
|
|
a96e2f0d78 | ||
|
|
17f7cb0d9c | ||
|
|
f6685ed22d | ||
|
|
52c18f7a45 | ||
|
|
fa7415fcd0 | ||
|
|
f8947a806d | ||
|
|
b98e503a61 | ||
|
|
5b832918df | ||
|
|
17bbcc1596 | ||
|
|
29be985c23 | ||
|
|
05d977666a | ||
|
|
e871991495 | ||
|
|
f9e19814eb | ||
|
|
af31315a5f | ||
|
|
474f68b34c | ||
|
|
1a724f20fe | ||
|
|
0da0bb2157 | ||
|
|
118e333ff8 | ||
|
|
c1334f3f85 | ||
|
|
70d379816c | ||
|
|
d8e7c13f6d | ||
|
|
1851a15e57 | ||
|
|
d9dbc9a59a | ||
|
|
d5e1332dda | ||
|
|
5ea0e87059 | ||
|
|
8e81266cdc | ||
|
|
70705ffe45 | ||
|
|
91c4da8a82 | ||
|
|
e9df5dccab | ||
|
|
990ef27cd2 | ||
|
|
0a17a9a2eb | ||
|
|
23907984a3 | ||
|
|
f0634e11f4 | ||
|
|
542ce76c0a | ||
|
|
2882b5fcb1 | ||
|
|
481b1a7b05 | ||
|
|
b58578f88d | ||
|
|
6cb285dd5c | ||
|
|
46f324b10b | ||
|
|
d188eec318 |
+23
-12
@@ -148,20 +148,34 @@ 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\n", request.Repository)
|
fmt.Fprintf(os.Stderr, "a build request arrived for %s (%s)\n", request.Repository, request.ID)
|
||||||
|
|
||||||
|
// **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,
|
Ref: request.Ref, On: on, Source: request.Source,
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "building %s", request.Repository)
|
what := "building " + request.Repository
|
||||||
if request.Path != "" {
|
if request.Path != "" {
|
||||||
fmt.Fprintf(os.Stderr, " at %s", request.Path)
|
what += " at " + request.Path
|
||||||
}
|
}
|
||||||
if request.Ref != "" {
|
if request.Ref != "" {
|
||||||
fmt.Fprintf(os.Stderr, " at %s", request.Ref)
|
what += " on " + request.Ref
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stderr)
|
say("build", what)
|
||||||
|
|
||||||
npmrc, err := packagesFrom()
|
npmrc, err := packagesFrom()
|
||||||
var built builder.Result
|
var built builder.Result
|
||||||
@@ -171,16 +185,13 @@ 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(),
|
forgeFrom(), say, request.Seats)
|
||||||
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()
|
||||||
fmt.Fprintf(os.Stderr, " failed: %v\n", err)
|
say("failed", err.Error())
|
||||||
} else {
|
} else {
|
||||||
manifest, marshalErr := json.Marshal(built.Manifest)
|
manifest, marshalErr := json.Marshal(built.Manifest)
|
||||||
if marshalErr != nil {
|
if marshalErr != nil {
|
||||||
@@ -197,7 +208,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})
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, " built %s from %s\n", built.Manifest.Module, short(built.Commit))
|
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ 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
|
||||||
@@ -57,6 +64,9 @@ 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,6 +91,10 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-41
@@ -10,6 +10,8 @@ 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"
|
||||||
@@ -175,10 +177,14 @@ 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]
|
||||||
@@ -219,8 +225,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\n",
|
fmt.Printf("%-18s %-14s %-10s %s %s\n",
|
||||||
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"))
|
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"), b.ID)
|
||||||
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)
|
||||||
@@ -408,11 +414,14 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -427,66 +436,91 @@ 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()
|
||||||
inv := open.inventory
|
manifest, kept, err := takeIn(ctx, open.inventory, result)
|
||||||
kept := buildFrom(result)
|
if err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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. The manifest as recorded, so the catalogue
|
|
||||||
// holds references by digest and path and every declaration composes the store's address in.
|
|
||||||
manifest, err := catalogue.ParseManifest(kept.Manifest)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
|
||||||
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{
|
|
||||||
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
|
||||||
BuiltFrom: result.Commit, Head: result.Commit,
|
|
||||||
}
|
|
||||||
if source.Seat != "" {
|
|
||||||
recorded.Repository, recorded.Seat = source.Repository, source.Seat
|
|
||||||
}
|
|
||||||
if err := inv.RegisterModule(ctx, manifest, recorded); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("\n%s %s, built on %s from %s\n",
|
fmt.Printf("\n%s %s, built on %s from %s\n",
|
||||||
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||||
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
||||||
|
result.On, result.Repository, err)
|
||||||
|
}
|
||||||
|
recorded := inventory.Source{
|
||||||
|
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
||||||
|
BuiltFrom: result.Commit, Head: result.Commit,
|
||||||
|
}
|
||||||
|
if result.Source != nil && result.Source.Seat != "" {
|
||||||
|
recorded.Repository, recorded.Seat = result.Source.Repository, result.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 {
|
||||||
|
return manifest, kept, err
|
||||||
|
}
|
||||||
|
return manifest, kept, nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildAndShow builds and prints the manifest without recording anything.
|
// buildAndShow builds and prints the manifest without recording anything.
|
||||||
func buildAndShow(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error {
|
func buildAndShow(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error {
|
||||||
repository, err := cloneFrom(ctx, source)
|
repository, err := cloneFrom(ctx, source)
|
||||||
@@ -513,7 +547,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),
|
Held: heldBy(ctx), Seats: seatBases(ctx),
|
||||||
}, wait)
|
}, wait)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -618,3 +652,57 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,7 @@ 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
|
||||||
@@ -240,6 +241,21 @@ 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 {
|
||||||
return b.inv.RecordBuild(ctx, buildFrom(result))
|
manifest, _, err := takeIn(ctx, b.inv, result)
|
||||||
|
switch {
|
||||||
|
case err != nil && result.Failed != "":
|
||||||
|
fmt.Printf("%s: %v\n", result.ID, err)
|
||||||
|
return nil
|
||||||
|
case err != nil:
|
||||||
|
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
|
||||||
|
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))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,24 @@ 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 list, or module forget <name>")
|
return errors.New("module add <file>, module check <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 {
|
||||||
@@ -96,6 +113,9 @@ 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
|
||||||
}
|
}
|
||||||
@@ -275,7 +295,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, list, moved, forget and issue", args[0])
|
return fmt.Errorf("module has no %q; it has add, check, list, moved, forget and issue", args[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,3 +665,18 @@ 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 - "))
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -124,6 +125,22 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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 "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 "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 "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, map[string]any{"node": "x", "module": "x", "repository": "x"}); 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}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,12 @@ 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)
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ 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)
|
||||||
@@ -94,9 +104,9 @@ func clonedFromSeat(world catalogue.World, seatName, repository string) (string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if holder == nil {
|
if holder == nil {
|
||||||
return "", fmt.Errorf("nobody holds the %s seat, so %s cannot be cloned from this mesh's "+
|
return "", fmt.Errorf("nobody holds the %s seat, so nothing can 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, repository)
|
seat.Name)
|
||||||
}
|
}
|
||||||
var provider *catalogue.Provider
|
var provider *catalogue.Provider
|
||||||
for i, p := range world.Offered[seat.Delivers] {
|
for i, p := range world.Offered[seat.Delivers] {
|
||||||
@@ -118,8 +128,33 @@ func clonedFromSeat(world catalogue.World, seatName, repository string) (string,
|
|||||||
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)
|
||||||
}
|
}
|
||||||
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
|
return fmt.Sprintf("%s://%s:%s", scheme, provider.At, port), nil
|
||||||
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.
|
||||||
|
|||||||
@@ -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: "the-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
|
[]DeclaredSeat{{Name: "mesh-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,7 +40,14 @@ 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
|
||||||
Why string
|
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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.>")
|
||||||
|
}
|
||||||
@@ -179,6 +179,7 @@ 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,
|
||||||
|
|||||||
+80
-17
@@ -39,7 +39,11 @@ 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
|
||||||
@@ -70,12 +74,14 @@ 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 a person may call, as `<module>.<tool>`; a single `*` is every tool,
|
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
|
||||||
// for an administrator. Only meaningful for KindPerson.
|
// tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so
|
||||||
|
// (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.
|
// What they have is permission to ask. A module that invokes gains exactly the same
|
||||||
|
// 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
|
||||||
@@ -195,6 +201,13 @@ 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
|
||||||
@@ -224,18 +237,11 @@ 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.
|
||||||
for _, t := range p.Invokes {
|
invoked, err := invokedSubjects(p.Invokes)
|
||||||
if t == "*" {
|
if err != nil {
|
||||||
pub = append(pub, "mesh.mod.*.tool.>")
|
return Permissions{}, err
|
||||||
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
|
||||||
@@ -293,6 +299,16 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// 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.>")
|
||||||
|
|
||||||
|
// 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).
|
||||||
for _, c := range p.Consumes {
|
for _, c := range p.Consumes {
|
||||||
@@ -338,7 +354,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, seatSubject(s, "tool", t))
|
sub = append(sub, seatToolSubject(s, t, p.Node))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +366,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, seatSubject(s, "tool", t))
|
pub = append(pub, seatToolSubject(s, t, "*"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,6 +416,18 @@ 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.
|
||||||
//
|
//
|
||||||
@@ -601,3 +629,38 @@ 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)
|
||||||
|
}
|
||||||
|
out = append(out, "mesh.mod."+module+".tool."+tool)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ 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) {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ func MeshStreams() []Stream {
|
|||||||
"n-1 by construction (issue 107)",
|
"n-1 by construction (issue 107)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "EVENTS",
|
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.
|
||||||
@@ -220,6 +220,9 @@ 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,
|
||||||
@@ -244,8 +247,13 @@ func MeshConsumers() []Consumer {
|
|||||||
Push: true,
|
Push: true,
|
||||||
AckWaitSeconds: 30,
|
AckWaitSeconds: 30,
|
||||||
MaxDeliver: 5,
|
MaxDeliver: 5,
|
||||||
Why: "the two events the mesh's own controller reacts to; after max-deliver it " +
|
// One at a time (novox/hq issue 175): acting on a merge builds for minutes, and an
|
||||||
"dead-letters, because an announcement it cannot act on will not become actionable",
|
// announcement handed over behind it must wait on the server, not time out on the
|
||||||
|
// 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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,3 +260,14 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ accounts {
|
|||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.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"] }
|
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.>"] }
|
||||||
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: {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ 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.
|
||||||
@@ -61,7 +63,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,
|
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-11
@@ -90,7 +90,13 @@ 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) (Result, error) {
|
forge GitCredential, log Log, seats ...map[string]string) (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))
|
||||||
@@ -209,7 +215,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
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, say)
|
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, 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
|
||||||
@@ -246,14 +252,18 @@ 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, say func(step, format string, args ...any)) (string, error) {
|
from catalogue.ArtifactContext, seats map[string]string, say func(step, format string, args ...any)) (string, error) {
|
||||||
say("context", "cloning %s at %s for %s", from.Repository, refOrHead(from.Ref), artifact)
|
url, err := contextURL(from, seats)
|
||||||
|
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", from.Repository, dir)...); err != nil {
|
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", url, dir)...); err != nil {
|
||||||
return "", fmt.Errorf("cannot clone %s: %w", from.Repository, err)
|
return "", fmt.Errorf("cannot clone %s: %w", url, 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 {
|
||||||
@@ -264,6 +274,23 @@ 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
|
||||||
@@ -416,7 +443,8 @@ 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, say func(step, format string, args ...any)) (catalogue.Built, error) {
|
held map[string]string, npmrc string, seats map[string]string,
|
||||||
|
say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||||
|
|
||||||
switch a.Kind {
|
switch a.Kind {
|
||||||
case catalogue.ArtifactUpstream:
|
case catalogue.ArtifactUpstream:
|
||||||
@@ -493,7 +521,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, say)
|
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, seats, 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)
|
||||||
}
|
}
|
||||||
@@ -691,6 +719,21 @@ 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
|
||||||
@@ -698,16 +741,23 @@ 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()
|
||||||
fmt.Fprintf(os.Stderr, " $ (%s) %s %s\n", short(filepath.Base(dir)), name, strings.Join(args, " "))
|
tell("run", "$ (%s) %s %s", 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 {
|
||||||
fmt.Fprintf(os.Stderr, " ! %s %s failed after %s\n", name, args[0], since(started))
|
tell("run", "! %s %s failed after %s", 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)))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, " ✓ %s %s (%s)\n", name, firstArg(args), since(started))
|
tell("run", "✓ %s %s (%s)", name, firstArg(args), since(started))
|
||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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: "the-artifact-store", Scope: ScopeMesh,
|
elsewhere := World{Held: []Held{{Claim: "mesh-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(), "the-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
|
if !strings.Contains(err.Error(), "mesh-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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,27 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
||||||
|
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
|
||||||
|
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
|
||||||
|
// what the module is called is the mesh's statement, not the provider's.
|
||||||
|
func withOwnNames(values map[string]string, own map[string]any) {
|
||||||
|
for _, key := range []string{"name", "internal-name"} {
|
||||||
|
if v, ok := own[key].(string); ok {
|
||||||
|
values[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
many, _ := own["names"].(map[string]any)
|
||||||
|
for local, raw := range many {
|
||||||
|
names, _ := raw.(map[string]any)
|
||||||
|
for _, key := range []string{"name", "internal-name"} {
|
||||||
|
if v, ok := names[key].(string); ok {
|
||||||
|
values[key+"-"+local] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// plainly renders a served value as a program would expect to read it.
|
// plainly renders a served value as a program would expect to read it.
|
||||||
func plainly(value any) string {
|
func plainly(value any) string {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
|
|||||||
@@ -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"},
|
||||||
{"the-artifact-store", "artifact-store"},
|
{"mesh-artifact-store", "artifact-store"},
|
||||||
{"mesh-store", "postgres-database"},
|
{"mesh-store", "postgres-database"},
|
||||||
{"mesh-broker", "mesh-bus"},
|
{"mesh-broker", "mesh-bus"},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package catalogue
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,11 +11,24 @@ 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.
|
||||||
func TestEveryCatalogueManifestParses(t *testing.T) {
|
// catalogueRoot is the catalogue these checks run over: MESH_CATALOGUE when set, else the checkout
|
||||||
root := os.Getenv("MESH_CATALOGUE")
|
// beside this one, the way the main layout has it. A check that only ran when somebody remembered a
|
||||||
if root == "" {
|
// variable was a check nobody ran (novox/hq issue 134, 2026-09-30); it skips only when there is no
|
||||||
t.Skip("set MESH_CATALOGUE to a catalogue checkout to run this")
|
// 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) {
|
||||||
|
root := catalogueRoot(t)
|
||||||
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)
|
||||||
@@ -45,3 +59,30 @@ 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 "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -197,6 +197,27 @@ 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",
|
||||||
|
|||||||
@@ -473,9 +473,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
// 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*.
|
||||||
for _, a := range m.Accesses {
|
// Where each is on THIS machine is the assignment's (novox/hq issue 153): placed by id
|
||||||
|
// 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.At(),
|
"id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.Mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, to := range m.SecretRequirements() {
|
for _, to := range m.SecretRequirements() {
|
||||||
@@ -574,7 +580,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
}
|
}
|
||||||
found = here
|
found = here
|
||||||
}
|
}
|
||||||
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)))
|
own, err := r.ownNames(m, to, with.Settings[m.Module])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -635,8 +645,43 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
}
|
}
|
||||||
// And what its bindings say, for the half of a connection that is not secret.
|
// And what its bindings say, for the half of a connection that is not secret.
|
||||||
known := knownFor(m, r.Needs, r.Node)
|
known := knownFor(m, r.Needs, r.Node)
|
||||||
|
// A requirement answered on this same machine is not in r.Needs — its binding file is
|
||||||
|
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
|
||||||
|
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
|
||||||
|
for _, want := range m.Wants() {
|
||||||
|
if _, has := known[want]; has {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
answered, err := here(r, want, with)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if answered == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
local := *answered
|
||||||
|
local.For = m.Module
|
||||||
|
for provision, values := range knownFor(m, []Needed{local}, r.Node) {
|
||||||
|
known[provision] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And what the module is called through each requirement it contributes to (novox/hq
|
||||||
|
// 04-ISSUES/122) — the same composition its binding file carries.
|
||||||
|
for provision, values := range known {
|
||||||
|
own, err := r.ownNames(m, provision, with.Settings[m.Module])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
withOwnNames(values, own)
|
||||||
|
}
|
||||||
// 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)
|
||||||
@@ -663,6 +708,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
// 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
|
||||||
@@ -670,6 +722,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
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
|
||||||
@@ -1087,21 +1145,11 @@ 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 := settle(m.Contributes[to], settings[m.Module], nil,
|
values, err := r.composed(m, to, m.Contributes[to], settings[m.Module],
|
||||||
m.Module+" contributing to "+to)
|
m.Module+" contributing to "+to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
reaches, err := Reaches(m, settings[m.Module])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
|
||||||
}
|
|
||||||
blocks, err := Endpoints(m, settings[m.Module])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
|
||||||
}
|
|
||||||
portOfEndpoint(values, endpointPorts(m))
|
|
||||||
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
|
|
||||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||||
}
|
}
|
||||||
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
|
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
|
||||||
@@ -1110,21 +1158,11 @@ 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 := settle(m.ContributesMany[to][local], settings[m.Module], nil,
|
values, err := r.composed(m, to, 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, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
reaches, err := Reaches(m, settings[m.Module])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
|
||||||
}
|
|
||||||
blocks, err := Endpoints(m, settings[m.Module])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
|
||||||
}
|
|
||||||
portOfEndpoint(values, endpointPorts(m))
|
|
||||||
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
|
|
||||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1132,6 +1170,85 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// composed is one contribution as its provider receives it: settled with this node's settings, its
|
||||||
|
// endpoint's port filled in, and its names composed from the label.
|
||||||
|
//
|
||||||
|
// **One function, because two readers must agree.** The provider is told the names in its received
|
||||||
|
// 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
|
||||||
|
// module wrote another into its configuration.
|
||||||
|
func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers []Layer, what string) (
|
||||||
|
map[string]any, error) {
|
||||||
|
// Overridden, not merged: a setting changes a key the contribution declares and adds none.
|
||||||
|
// 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 {
|
||||||
|
return nil, fmt.Errorf("%s: %w", what, err)
|
||||||
|
}
|
||||||
|
reaches, err := Reaches(m, layers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", what, err)
|
||||||
|
}
|
||||||
|
blocks, err := Endpoints(m, layers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", what, err)
|
||||||
|
}
|
||||||
|
portOfEndpoint(values, endpointPorts(m))
|
||||||
|
composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks)
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownNames is what a module is known by through what it contributes to one requirement — the names
|
||||||
|
// the mesh composed for it, and nothing else of the contribution.
|
||||||
|
//
|
||||||
|
// **The half a module could not learn** (novox/hq 04-ISSUES/122). A module contributes a label, the
|
||||||
|
// mesh joins it with this node's domains, and the provider serves the result — and the module itself
|
||||||
|
// was never told. Software that must know its own address (a login redirect, a canonical URL, an
|
||||||
|
// issuer) had it written into the manifest as a literal, which is a domain in a definition and wrong
|
||||||
|
// on every other machine. `${bound:<requirement>:name}` is the answer, from the same composition the
|
||||||
|
// provider receives.
|
||||||
|
//
|
||||||
|
// Several contributions to one requirement are keyed by their local name under `names`.
|
||||||
|
func (r Resolution) ownNames(m Manifest, to string, layers []Layer) (map[string]any, error) {
|
||||||
|
pick := func(values map[string]any) map[string]any {
|
||||||
|
names := map[string]any{}
|
||||||
|
for _, key := range []string{"name", "internal-name"} {
|
||||||
|
if v, ok := values[key].(string); ok && v != "" {
|
||||||
|
names[key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
out := map[string]any{}
|
||||||
|
if raw, ok := m.Contributes[to]; ok {
|
||||||
|
values, err := r.composed(m, to, raw, layers, m.Module+" contributing to "+to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for k, v := range pick(values) {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if locals := m.ContributesMany[to]; len(locals) > 0 {
|
||||||
|
many := map[string]any{}
|
||||||
|
for _, local := range sortedKeys(locals) {
|
||||||
|
values, err := r.composed(m, to, locals[local], layers,
|
||||||
|
m.Module+" contributing "+local+" to "+to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if names := pick(values); len(names) > 0 {
|
||||||
|
many[local] = names
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(many) > 0 {
|
||||||
|
out["names"] = many
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// composeName joins a contribution's label with a node's public domain, and separately with its
|
// composeName joins a contribution's label with a node's public domain, and separately with its
|
||||||
// private one, in place (novox/hq ADR 0056).
|
// private one, in place (novox/hq ADR 0056).
|
||||||
//
|
//
|
||||||
@@ -1224,6 +1341,24 @@ 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 {
|
||||||
@@ -1328,14 +1463,14 @@ func sortedKeys[V any](m map[string]V) []string {
|
|||||||
// Where it is and what the providing module said about using it. **No credential**, and the file
|
// Where it is and what the providing module said about using it. **No credential**, and the file
|
||||||
// says so rather than leaving a reader to wonder whether one was meant to be there — a missing
|
// says so rather than leaving a reader to wonder whether one was meant to be there — a missing
|
||||||
// field looks like a bug, and a stated absence looks like a boundary.
|
// field looks like a bug, and a stated absence looks like a boundary.
|
||||||
func boundFile(n Needed, path, as string) (map[string]any, error) {
|
func boundFile(n Needed, path, as string, own map[string]any) (map[string]any, error) {
|
||||||
// A record has no machine and no address. Saying so is the difference between a reader
|
// A record has no machine and no address. Saying so is the difference between a reader
|
||||||
// concluding "somewhere with no address" and concluding the mesh failed to fill something in.
|
// concluding "somewhere with no address" and concluding the mesh failed to fill something in.
|
||||||
where := any(n.At)
|
where := any(n.At)
|
||||||
if n.ByRecord {
|
if n.ByRecord {
|
||||||
where = "a record in this mesh, not a machine"
|
where = "a record in this mesh, not a machine"
|
||||||
}
|
}
|
||||||
body, err := json.MarshalIndent(map[string]any{
|
doc := map[string]any{
|
||||||
"binding": 1,
|
"binding": 1,
|
||||||
"provision": n.Name,
|
"provision": n.Name,
|
||||||
"from": n.From,
|
"from": n.From,
|
||||||
@@ -1351,7 +1486,14 @@ func boundFile(n Needed, path, as string) (map[string]any, error) {
|
|||||||
"generated": "by the mesh — do not edit; replaced whenever this changes. " +
|
"generated": "by the mesh — do not edit; replaced whenever this changes. " +
|
||||||
"The credential is not here: it is sealed, in the file this module's manifest " +
|
"The credential is not here: it is sealed, in the file this module's manifest " +
|
||||||
"names under `secrets`",
|
"names under `secrets`",
|
||||||
}, "", " ")
|
}
|
||||||
|
// **What this module is called through what it contributes here** (novox/hq 04-ISSUES/122):
|
||||||
|
// `name`, `internal-name`, or `names` by local name — composed exactly as the provider receives
|
||||||
|
// them. Absent when the module contributes nothing named, rather than written empty.
|
||||||
|
for key, value := range own {
|
||||||
|
doc[key] = value
|
||||||
|
}
|
||||||
|
body, err := json.MarshalIndent(doc, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1558,14 +1700,23 @@ 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
|
||||||
}
|
}
|
||||||
wanted, err := strconv.Atoi(strings.TrimSpace(written))
|
// A short form may carry the protocol — `"3478/udp"` — and the number is what the mesh
|
||||||
|
// 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", with.machinePort(module, wanted), wanted))
|
out = append(out, fmt.Sprintf("%d:%d%s", with.machinePort(module, wanted), wanted, protocol))
|
||||||
}
|
}
|
||||||
resource["ports"] = out
|
resource["ports"] = out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ 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).
|
||||||
@@ -27,6 +33,15 @@ 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-]*)\}`)
|
||||||
|
|
||||||
@@ -40,26 +55,53 @@ 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> —
|
// A pathless directory saying `"place": "."` is the assignment's own root, <root>/<module>; one
|
||||||
// to-be 27's one directory per assignment, which every other placed thing sits beneath. At most
|
// saying `"place": "mesh"` is the mesh's directory for the module, <root>/mesh/<module>; one
|
||||||
// one makes sense; nothing enforces one, because two ids resolving to one path is a mistake the
|
// saying neither is <root>/<module>/<id>. At most one of each place makes sense; nothing enforces
|
||||||
// module's own files make visible immediately.
|
// one, because two ids resolving to one path is a mistake the module's own files make visible
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
if place, said := r["place"].(string); said && place == "." {
|
switch place, _ := r["place"].(string); place {
|
||||||
|
case placeOwn:
|
||||||
dirs[id] = dataRoot(with) + "/" + m.Module
|
dirs[id] = dataRoot(with) + "/" + m.Module
|
||||||
continue
|
case placeMesh:
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -244,10 +286,11 @@ 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 != "." {
|
if place != placeOwn && place != placeMesh {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s says place %q on %v, and the only place is %q — the assignment's own root",
|
"%s says place %q on %v, and the places are %q — the assignment's own root — and "+
|
||||||
m.Module, place, r["id"], "."))
|
"%q — where the mesh keeps what it writes for the module",
|
||||||
|
m.Module, place, r["id"], placeOwn, placeMesh))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|||||||
@@ -216,8 +216,48 @@ 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 only place is "."`) {
|
if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) {
|
||||||
t.Fatalf("a place that is not the root refuses; got %v", got)
|
t.Fatalf("a place that is neither 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: map[string]string{"broker": "${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"] != "/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"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,3 +290,36 @@ 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,3 +158,16 @@ 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +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"`
|
||||||
@@ -86,8 +91,13 @@ 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 {
|
||||||
// Path is the absolute path on the machine, as the operator provides it.
|
// ID is the name the module gives this access, which the assignment places
|
||||||
Path string `json:"path"`
|
// (`accesses: {<id>: <path>}`, novox/hq ADR 0112, issue 153) and the module's mounts name as
|
||||||
|
// ${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"`
|
||||||
}
|
}
|
||||||
@@ -247,6 +257,16 @@ 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"`
|
||||||
|
|
||||||
|
// 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.
|
||||||
@@ -534,8 +554,14 @@ 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.
|
// than from whatever a previous build happened to leave behind. A URL, or — with Seat — a
|
||||||
|
// 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"`
|
||||||
@@ -1290,6 +1316,7 @@ 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 {
|
||||||
@@ -1503,7 +1530,16 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, a := range m.Accesses {
|
for _, a := range m.Accesses {
|
||||||
if !strings.HasPrefix(a.Path, "/") {
|
if a.ID == "" && 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))
|
||||||
}
|
}
|
||||||
@@ -1535,6 +1571,7 @@ 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)
|
||||||
@@ -1766,3 +1803,26 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module that must know its own address — a login redirect, a canonical URL, an issuer — had it
|
||||||
|
// written into its manifest as a literal (novox/hq 04-ISSUES/122): a domain in a definition, wrong on
|
||||||
|
// every other machine. It is told instead, from the same composition the provider receives.
|
||||||
|
|
||||||
|
// selfAware contributes a labelled route, binds the requirement, and writes its own name into a file.
|
||||||
|
func selfAware(label string) Manifest {
|
||||||
|
m := labelled("board", label, 8080)
|
||||||
|
m.Requires = []string{"reverse-proxy"}
|
||||||
|
m.Binds = map[string]string{"reverse-proxy": "/var/lib/board/route.json"}
|
||||||
|
m.Resources = []map[string]any{
|
||||||
|
{"id": "conf", "type": "file", "path": "/var/lib/board/app.conf",
|
||||||
|
"content": "root = https://${bound:reverse-proxy:name}/\ninternal = ${bound:reverse-proxy:internal-name}\n"},
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// servingProxy is proxy() as the catalogue's route providers are declared: the provision scoped to
|
||||||
|
// the mesh, serving nothing a consumer must know (route-adapter, route-proxy: `"serves": {"route": {}}`).
|
||||||
|
func servingProxy() Manifest {
|
||||||
|
p := proxy()
|
||||||
|
p.Provides = []Offer{{Name: "reverse-proxy", Scope: ScopeMesh}}
|
||||||
|
p.Serves = map[string]map[string]any{"reverse-proxy": {}}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// nodeProxy is the same provider scoped to its node, whose answer on the same machine comes from
|
||||||
|
// `here` rather than from the mesh's needs — the other path a binding is written by.
|
||||||
|
func nodeProxy() Manifest {
|
||||||
|
p := proxy()
|
||||||
|
p.Serves = map[string]map[string]any{"reverse-proxy": {"scheme": "http"}}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// onBoth is a node with a public domain and a private-network address, so both names compose.
|
||||||
|
func onBoth(domain string) Node {
|
||||||
|
n := withDomain(domain)
|
||||||
|
n.At = "anchor.internal"
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileAt(t *testing.T, out []map[string]any, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range out {
|
||||||
|
if r["path"] == path {
|
||||||
|
return r["content"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("nothing was declared at %s", path)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAModuleIsToldTheNameItsProviderServes(t *testing.T) {
|
||||||
|
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
|
||||||
|
onBoth("example.tld"), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := mustDeclare(t, got)
|
||||||
|
served := received(t, out)[0].Values
|
||||||
|
|
||||||
|
var binding map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(fileAt(t, out, "/var/lib/board/route.json")), &binding); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if binding["name"] != served["name"] || binding["name"] != "git.example.tld" {
|
||||||
|
t.Fatalf("the module was told %v, the provider serves %v", binding["name"], served["name"])
|
||||||
|
}
|
||||||
|
if binding["internal-name"] != served["internal-name"] || binding["internal-name"] == nil {
|
||||||
|
t.Fatalf("internal name: module told %v, provider serves %v",
|
||||||
|
binding["internal-name"], served["internal-name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
conf := fileAt(t, out, "/var/lib/board/app.conf")
|
||||||
|
want := "root = https://git.example.tld/\ninternal = " + served["internal-name"].(string) + "\n"
|
||||||
|
if conf != want {
|
||||||
|
t.Fatalf("the file was rendered as\n%s\nwant\n%s", conf, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheNameAModuleIsToldFollowsTheNodesDomain(t *testing.T) {
|
||||||
|
// The whole point: the same definition, two machines, two names — nothing edited.
|
||||||
|
for _, domain := range []string{"example.tld", "other.example"} {
|
||||||
|
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
|
||||||
|
onBoth(domain), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
|
||||||
|
if !strings.HasPrefix(conf, "root = https://git."+domain+"/") {
|
||||||
|
t.Fatalf("on %s the module wrote %q", domain, conf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAModuleWithNoPublicNameIsNotToldOne(t *testing.T) {
|
||||||
|
// No public domain on the node: nothing composed, so no `name` — and a file asking for one is
|
||||||
|
// refused rather than rendered with a placeholder or an empty host.
|
||||||
|
m := selfAware("git")
|
||||||
|
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
|
||||||
|
got, err := Resolve(shelf(servingProxy(), m), []string{"traefik", "board"}, workstation(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := got.Declaration(Rendering{}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), `"name"`) {
|
||||||
|
t.Fatalf("a file asking for a name that was never composed was not refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAModuleIsToldItsNameByANodeScopedProviderToo(t *testing.T) {
|
||||||
|
m := selfAware("git")
|
||||||
|
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
|
||||||
|
got, err := Resolve(shelf(nodeProxy(), m), []string{"board"},
|
||||||
|
withDomain("example.tld"), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
|
||||||
|
if !strings.HasPrefix(conf, "root = https://git.example.tld/") {
|
||||||
|
t.Fatalf("a same-machine, node-scoped answer did not tell the module its name: %q", conf)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -707,9 +707,14 @@ 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",
|
"%s on %s claims %q, which %s on %s already holds — one per mesh. Nothing is "+
|
||||||
h.Module, node.Name, h.Claim, e.Module, e.Node))
|
"on record for it; `seat %s --to %s/%s` records the holder, and the other "+
|
||||||
|
"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(
|
||||||
@@ -786,6 +791,9 @@ 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.
|
||||||
|
|||||||
@@ -162,6 +162,13 @@ 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.
|
||||||
@@ -187,6 +194,14 @@ 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,3 +258,24 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -228,3 +228,29 @@ func TestARoutedNameResolvesToTheServingNode(t *testing.T) {
|
|||||||
t.Fatalf("the routed name is not in the roster the machine's resolver answers from")
|
t.Fatalf("the routed name is not in the roster the machine's resolver answers from")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,7 +37,9 @@ 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 []string
|
// Serves carries each verb in full — name, description, schema — because a role's tools are the
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
@@ -51,8 +53,12 @@ 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.
|
||||||
{Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
// And it serves the mesh's own verbs as the seat's tools (novox/hq ADR 0154): `status`, `push`,
|
||||||
Emits: []string{"applied", "refused", "built-before"}},
|
// `assign` and the rest are a role's interface, not a container's, and stay addressable while
|
||||||
|
// the control plane is replaced.
|
||||||
|
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
||||||
|
Emits: []string{"applied", "refused", "built-before"},
|
||||||
|
Serves: ControllerVerbs},
|
||||||
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"},
|
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"},
|
||||||
// **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
|
||||||
@@ -60,7 +66,11 @@ var defaultSeats = []Seat{
|
|||||||
// 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"},
|
||||||
{Name: "the-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
// 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.
|
||||||
@@ -70,8 +80,11 @@ 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{"built"}, Decision: "novox/hq ADR 0121"},
|
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, 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"},
|
||||||
@@ -91,8 +104,9 @@ 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-artifact-store,
|
// convention and are not yet renamed (git, npm-package-registry, the-private-network) — those are
|
||||||
// the-private-network) — those are in the set, so they resolve by name, not by prefix.
|
// in the set, so they resolve by name, not by prefix. the-artifact-store was renamed on 2026-09-30
|
||||||
|
// (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-")
|
||||||
}
|
}
|
||||||
@@ -269,6 +283,14 @@ 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(m.Tools, 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 lists every verb its seat declares under tools",
|
||||||
|
m.Module, seat.Name, strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ 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.
|
// Serves are the verbs the holder answers: request and reply, awaited. A bare name, or the
|
||||||
Serves []string `json:"serves,omitempty"`
|
// verb in full with its schema (novox/hq ADR 0132).
|
||||||
|
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
|
||||||
@@ -62,7 +63,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, s.Serves...)
|
return append(out, VerbNames(s.Serves)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// declaredSeatProblems is what one manifest can be judged on alone.
|
// declaredSeatProblems is what one manifest can be judged on alone.
|
||||||
@@ -228,8 +229,8 @@ func unserved(m Manifest, s SeatDeclaration) []string {
|
|||||||
}
|
}
|
||||||
var missing []string
|
var missing []string
|
||||||
for _, t := range s.Serves {
|
for _, t := range s.Serves {
|
||||||
if !has[t] {
|
if !has[t.Name] {
|
||||||
missing = append(missing, t)
|
missing = append(missing, t.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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: []string{"status"},
|
Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []Verb{{Name: "status"}},
|
||||||
}}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}}
|
}}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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,17 +85,67 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settle lays settings over a module's own values. Exported for what a provider serves, which is
|
// Settle lays settings over what a provider serves. Exported because a served fact is settled where
|
||||||
// settled where the mesh is walked rather than where a node is declared.
|
// 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 settle(base, layers, nil, "what is served")
|
return overridden(base, layers, "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.
|
// could not be settled would have to be edited to be reused anywhere else. The two differ in one
|
||||||
|
// 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)
|
||||||
@@ -149,23 +199,22 @@ func deepCopy(in map[string]any) map[string]any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnusedSettings names settings that reached no file.
|
// UnusedSettings names settings that reach nothing.
|
||||||
//
|
//
|
||||||
// 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.
|
||||||
@@ -173,9 +222,30 @@ 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 {
|
||||||
@@ -197,9 +267,18 @@ 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 or contribution to merge it into",
|
"%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
|
||||||
layer.From, key, m.Module))
|
"declares no %q in what it contributes or serves",
|
||||||
|
layer.From, key, m.Module, key, key))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Strings(unused)
|
sort.Strings(unused)
|
||||||
|
|||||||
@@ -167,11 +167,45 @@ 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 or contribution to merge it into") {
|
if len(unused) != 1 || !strings.Contains(unused[0], "no file that merges it") {
|
||||||
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)
|
||||||
@@ -179,3 +213,24 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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: "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: "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: "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
|
||||||
|
}
|
||||||
|
|
||||||
|
// unservedVerbs is what a seat promises and a claimant's `tools` 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A served verb is written as a bare name or in full, and both read into one type (novox/hq ADR 0132).
|
||||||
|
func TestAServedVerbIsANameOrADefinition(t *testing.T) {
|
||||||
|
m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["price","refund"],` +
|
||||||
|
`"seats":[{"name":"shop-till","serves":["price",{"name":"refund","description":"give it back",` +
|
||||||
|
`"input":{"type":"object","properties":{"order":{"type":"string"}}}}]}],` +
|
||||||
|
`"claims":[{"name":"shop-till","scope":"mesh"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := m.DefinesSeats[0].Serves
|
||||||
|
if len(got) != 2 || got[0].Name != "price" || got[1].Name != "refund" || got[1].Description != "give it back" {
|
||||||
|
t.Fatalf("verbs not read: %+v", got)
|
||||||
|
}
|
||||||
|
if got[1].Input["type"] != "object" {
|
||||||
|
t.Fatalf("the schema did not travel with the verb: %+v", got[1].Input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A misspelt key inside a verb's definition is refused, like one anywhere else in the manifest.
|
||||||
|
func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) {
|
||||||
|
_, err := ParseManifest([]byte(`{"module":"till","version":"1",` +
|
||||||
|
`"seats":[{"name":"shop-till","serves":[{"name":"price","descripton":"typo"}]}]}`))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "descripton") {
|
||||||
|
t.Fatalf("a verb with a misspelt key was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
|
||||||
|
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
|
||||||
|
was := Seats()
|
||||||
|
t.Cleanup(func() { UseSeats(was) })
|
||||||
|
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
|
||||||
|
Serves: []Verb{{Name: "status"}, {Name: "push"}}}})
|
||||||
|
seat, _ := SeatNamed("mesh-controller")
|
||||||
|
|
||||||
|
partial := Manifest{Module: "a-controller", Tools: []string{"status"},
|
||||||
|
Claims: []Claim{{Name: "mesh-controller", Scope: ScopeMesh}}}
|
||||||
|
err := CanHold(partial, seat)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "does not serve push") {
|
||||||
|
t.Fatalf("a holder missing a verb was not refused by name: %v", err)
|
||||||
|
}
|
||||||
|
whole := Manifest{Module: "a-controller", Tools: []string{"status", "push"},
|
||||||
|
Claims: []Claim{{Name: "mesh-controller", Scope: ScopeMesh}}}
|
||||||
|
if err := CanHold(whole, seat); err != nil {
|
||||||
|
t.Fatalf("a holder serving every verb was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mesh's own verbs are declared in full: an agent cannot call a name without a schema.
|
||||||
|
func TestEveryControllerVerbIsDescribedWithASchema(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, v := range ControllerVerbs {
|
||||||
|
if v.Description == "" || v.Input == nil || v.Input["type"] != "object" {
|
||||||
|
t.Errorf("%s: no description or no object schema", v.Name)
|
||||||
|
}
|
||||||
|
if seen[v.Name] {
|
||||||
|
t.Errorf("%s declared twice", v.Name)
|
||||||
|
}
|
||||||
|
seen[v.Name] = true
|
||||||
|
}
|
||||||
|
seat, _ := SeatNamed(ControllerSeatName)
|
||||||
|
if len(seat.Serves) != len(ControllerVerbs) {
|
||||||
|
t.Fatalf("the compiled mesh-controller seat serves %d verbs, the table has %d", len(seat.Serves), len(ControllerVerbs))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
// The tools it answers, which is `tools` and not `serves`: the manifest's `serves` is the
|
// The tools it answers, which is `tools` and not `serves`: the manifest's `serves` is the
|
||||||
// facts a consumer needs to reach a provision, a different meaning under a similar word.
|
// facts a consumer needs to reach a provision, a different meaning under a similar word.
|
||||||
Serves: m.Tools,
|
Serves: m.Tools,
|
||||||
|
// And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's.
|
||||||
|
Invokes: m.Invokes,
|
||||||
}
|
}
|
||||||
for _, c := range m.Claims {
|
for _, c := range m.Claims {
|
||||||
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
||||||
@@ -135,7 +137,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
}
|
}
|
||||||
|
|
||||||
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
||||||
return broker.Seat{Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves}
|
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
||||||
|
Serves: catalogue.VerbNames(s.Serves)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
|
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
|
||||||
@@ -144,7 +147,7 @@ func MeshSeats() []broker.DeclaredSeat {
|
|||||||
var out []broker.DeclaredSeat
|
var out []broker.DeclaredSeat
|
||||||
for _, s := range catalogue.SeatsWithAProtocol() {
|
for _, s := range catalogue.SeatsWithAProtocol() {
|
||||||
out = append(out, broker.DeclaredSeat{
|
out = append(out, broker.DeclaredSeat{
|
||||||
Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves,
|
Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: catalogue.VerbNames(s.Serves),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- A seat's protocol lives in the store, not in the binary (novox/hq ADR 0129, ADR 0132, design 33 §2).
|
||||||
|
--
|
||||||
|
-- ADR 0122 moved the seat set into this table with name, scope, delivers and decision, and the
|
||||||
|
-- protocol — what a role accepts, emits and serves — stayed compiled into the control plane and was
|
||||||
|
-- merged in as a row was read. Discovery that reads a binary disagrees with the mesh the moment the
|
||||||
|
-- two are on different versions, and a tool without a schema is not something an agent can call. So
|
||||||
|
-- the three halves become columns: accepts and emits as lists of verbs, serves as the verbs in full
|
||||||
|
-- ({name, description, input, output}). Seeded from the compiled defaults where a row has none,
|
||||||
|
-- additively thereafter (a verb a release adds joins the row; nothing is taken away).
|
||||||
|
alter table seat add column accepts jsonb not null default '[]'::jsonb;
|
||||||
|
alter table seat add column emits jsonb not null default '[]'::jsonb;
|
||||||
|
alter table seat add column serves jsonb not null default '[]'::jsonb;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- The artifact store's seat is named for its scope, like the mesh's other seats (novox/hq ADR 0121,
|
||||||
|
-- ADR 0156, issue 123).
|
||||||
|
--
|
||||||
|
-- `the-artifact-store` was the last of the mesh's own seats named for the job it happened to do rather
|
||||||
|
-- than for the mesh; ADR 0121 decided the rename and deferred it because a delivering seat that stops
|
||||||
|
-- resolving mid-flight takes a provision away from every consumer. ADR 0122 removed that risk: a seat's
|
||||||
|
-- former name is an alias that resolves to it forever, a held record follows the rename by cascade, and
|
||||||
|
-- a claim written with the old name still holds.
|
||||||
|
--
|
||||||
|
-- **Both rows may exist when this runs.** A controller whose compiled defaults already carry the new
|
||||||
|
-- name seeds it as a new seat the moment it can, and on the mesh this was written for that happened
|
||||||
|
-- before the rename: the first form of this migration renamed into a duplicate key and the control
|
||||||
|
-- node's prepare failed on every attempt (2026-09-30). So: if the new row is already there, the old
|
||||||
|
-- row's holding moves to it and the old row goes; otherwise the old row is renamed. Either way the old
|
||||||
|
-- name becomes an alias.
|
||||||
|
update seat_holding set seat = 'mesh-artifact-store'
|
||||||
|
where seat = 'the-artifact-store'
|
||||||
|
and exists (select 1 from seat where name = 'mesh-artifact-store');
|
||||||
|
delete from seat
|
||||||
|
where name = 'the-artifact-store'
|
||||||
|
and exists (select 1 from seat where name = 'mesh-artifact-store');
|
||||||
|
update seat set name = 'mesh-artifact-store' where name = 'the-artifact-store';
|
||||||
|
insert into seat_alias (alias, seat) values ('the-artifact-store', 'mesh-artifact-store')
|
||||||
|
on conflict (alias) do update set seat = excluded.seat;
|
||||||
|
update seat_alias set seat = 'mesh-artifact-store' where seat = 'the-artifact-store';
|
||||||
+115
-7
@@ -2,6 +2,7 @@ package inventory
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -17,7 +18,7 @@ import (
|
|||||||
// Seats is every seat the mesh defines, read from the store.
|
// Seats is every seat the mesh defines, read from the store.
|
||||||
func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
|
func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
`select name, scope, delivers, decided from seat order by name`)
|
`select name, scope, delivers, decided, accepts, emits, serves from seat order by name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -26,9 +27,21 @@ func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
|
|||||||
var seats []catalogue.Seat
|
var seats []catalogue.Seat
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s catalogue.Seat
|
var s catalogue.Seat
|
||||||
if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision); err != nil {
|
var accepts, emits, serves []byte
|
||||||
|
if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision, &accepts, &emits, &serves); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// The protocol, from the row (novox/hq ADR 0132). A row that predates the columns has empty
|
||||||
|
// lists, and UseSeats keeps the compiled protocol for it until the next seeding fills them.
|
||||||
|
if err := json.Unmarshal(accepts, &s.Accepts); err != nil {
|
||||||
|
return nil, fmt.Errorf("seat %s: accepts: %w", s.Name, err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(emits, &s.Emits); err != nil {
|
||||||
|
return nil, fmt.Errorf("seat %s: emits: %w", s.Name, err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(serves, &s.Serves); err != nil {
|
||||||
|
return nil, fmt.Errorf("seat %s: serves: %w", s.Name, err)
|
||||||
|
}
|
||||||
seats = append(seats, s)
|
seats = append(seats, s)
|
||||||
}
|
}
|
||||||
return seats, rows.Err()
|
return seats, rows.Err()
|
||||||
@@ -41,21 +54,116 @@ func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
|
|||||||
// exactly as it is, so an operator's rename in the table is not undone by the next deploy putting
|
// exactly as it is, so an operator's rename in the table is not undone by the next deploy putting
|
||||||
// the old name back. What a release removes from the defaults is not deleted here either; retiring a
|
// the old name back. What a release removes from the defaults is not deleted here either; retiring a
|
||||||
// seat is its own decision, not a silent consequence of it dropping out of the binary.
|
// seat is its own decision, not a silent consequence of it dropping out of the binary.
|
||||||
|
//
|
||||||
|
// **The protocol is seeded additively** (novox/hq ADR 0132, design 33 §7). A row that has none takes
|
||||||
|
// the compiled protocol whole — that is the compiled fallback becoming data, once. A row that has one
|
||||||
|
// gains any verb the defaults name and it lacks, and loses nothing: a seat's tools are an interface,
|
||||||
|
// additive within a version, and a verb an operator added to the row is theirs to keep.
|
||||||
func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) {
|
func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) {
|
||||||
var added int
|
var added int
|
||||||
for _, s := range defaults {
|
for _, s := range defaults {
|
||||||
tag, err := i.store.Pool().Exec(ctx,
|
accepts, emits, serves, err := protocolJSON(s)
|
||||||
`insert into seat (name, scope, delivers, decided) values ($1, $2, $3, $4)
|
|
||||||
on conflict (name) do nothing`,
|
|
||||||
s.Name, s.Scope, s.Delivers, s.Decision)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return added, err
|
return added, err
|
||||||
}
|
}
|
||||||
added += int(tag.RowsAffected())
|
tag, err := i.store.Pool().Exec(ctx,
|
||||||
|
`insert into seat (name, scope, delivers, decided, accepts, emits, serves)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
on conflict (name) do nothing`,
|
||||||
|
s.Name, s.Scope, s.Delivers, s.Decision, accepts, emits, serves)
|
||||||
|
if err != nil {
|
||||||
|
return added, err
|
||||||
|
}
|
||||||
|
if n := int(tag.RowsAffected()); n > 0 {
|
||||||
|
added += n
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := i.widenProtocol(ctx, s); err != nil {
|
||||||
|
return added, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return added, nil
|
return added, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// widenProtocol adds to a seat's row whatever the defaults name and the row lacks, by verb name.
|
||||||
|
func (i *Inventory) widenProtocol(ctx context.Context, s catalogue.Seat) error {
|
||||||
|
var accepts, emits, serves []byte
|
||||||
|
if err := i.store.Pool().QueryRow(ctx,
|
||||||
|
`select accepts, emits, serves from seat where name = $1`, s.Name).Scan(&accepts, &emits, &serves); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var row catalogue.Seat
|
||||||
|
if err := json.Unmarshal(accepts, &row.Accepts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(emits, &row.Emits); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(serves, &row.Serves); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
row.Accepts, changed = union(row.Accepts, s.Accepts, changed)
|
||||||
|
row.Emits, changed = union(row.Emits, s.Emits, changed)
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, v := range row.Serves {
|
||||||
|
have[v.Name] = true
|
||||||
|
}
|
||||||
|
for _, v := range s.Serves {
|
||||||
|
if !have[v.Name] {
|
||||||
|
row.Serves = append(row.Serves, v)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a, e, sv, err := protocolJSON(row)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
|
`update seat set accepts = $2, emits = $3, serves = $4 where name = $1`, s.Name, a, e, sv)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func union(have, want []string, changed bool) ([]string, bool) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, h := range have {
|
||||||
|
seen[h] = true
|
||||||
|
}
|
||||||
|
for _, w := range want {
|
||||||
|
if !seen[w] {
|
||||||
|
have = append(have, w)
|
||||||
|
seen[w] = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return have, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func protocolJSON(s catalogue.Seat) (accepts, emits, serves []byte, err error) {
|
||||||
|
if accepts, err = json.Marshal(orEmpty(s.Accepts)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if emits, err = json.Marshal(orEmpty(s.Emits)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verbs := s.Serves
|
||||||
|
if verbs == nil {
|
||||||
|
verbs = []catalogue.Verb{}
|
||||||
|
}
|
||||||
|
serves, err = json.Marshal(verbs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func orEmpty(s []string) []string {
|
||||||
|
if s == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122).
|
// Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122).
|
||||||
func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) {
|
func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) {
|
||||||
rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`)
|
rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`)
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ type BuildRequest struct {
|
|||||||
// written in a manifest the mesh has not read: it is inside the repository, and reading it is
|
// written in a manifest the mesh has not read: it is inside the repository, and reading it is
|
||||||
// the build's first act.
|
// the build's first act.
|
||||||
Held map[string]string `json:"held,omitempty"`
|
Held map[string]string `json:"held,omitempty"`
|
||||||
|
// Seats is the clone base — `scheme://host:port` — of each seat a recipe's context may name
|
||||||
|
// (novox/hq ADR 0155): `git` for this mesh's own forge. Sent with the asking for the reason
|
||||||
|
// Held is: the context is written in a manifest the mesh has not read, and only the mesh knows
|
||||||
|
// which forge holds the seat here. A builder handed no base for a seat a context names refuses
|
||||||
|
// the build and says so.
|
||||||
|
Seats map[string]string `json:"seats,omitempty"`
|
||||||
|
// Source is the repository as the mesh records it when it lives on a seat's holder — the seat
|
||||||
|
// and the path on it, never the URL just composed (novox/hq ADR 0111). Carried with the
|
||||||
|
// asking and echoed in the outcome, so whoever hears the outcome can register the module with
|
||||||
|
// its true source, whether or not they were the one who asked (novox/hq issue 176).
|
||||||
|
Source *SourceOnSeat `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceOnSeat names a repository by the seat whose holder serves it and its path there.
|
||||||
|
type SourceOnSeat struct {
|
||||||
|
Seat string `json:"seat"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildResult is what a builder says back.
|
// BuildResult is what a builder says back.
|
||||||
@@ -95,6 +112,9 @@ type BuildResult struct {
|
|||||||
|
|
||||||
// Failed is why, when it did.
|
// Failed is why, when it did.
|
||||||
Failed string `json:"failed,omitempty"`
|
Failed string `json:"failed,omitempty"`
|
||||||
|
|
||||||
|
// Source is the request's, echoed: the seat form of the repository, for whoever registers.
|
||||||
|
Source *SourceOnSeat `json:"source,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadRepository is a repository a build read source from besides the module's own, at the branch,
|
// ReadRepository is a repository a build read source from besides the module's own, at the branch,
|
||||||
|
|||||||
@@ -27,6 +27,40 @@ const TheBuildMachine = "mesh-build-machine"
|
|||||||
func BuildWork() string { return "mesh.seat." + TheBuildMachine + ".accept.build" }
|
func BuildWork() string { return "mesh.seat." + TheBuildMachine + ".accept.build" }
|
||||||
func BuildOutcome() string { return "mesh.seat." + TheBuildMachine + ".event.built" }
|
func BuildOutcome() string { return "mesh.seat." + TheBuildMachine + ".event.built" }
|
||||||
|
|
||||||
|
// BuildStarted is where a build machine says it has taken a build, and BuildLog is where it says
|
||||||
|
// what it is doing, one line per message, under the build's own id (novox/hq ADR 0157).
|
||||||
|
//
|
||||||
|
// **The whole build is on the bus as it happens.** The outcome alone told a person that a build
|
||||||
|
// failed and its first line why; everything between — which command, how long, where it hung —
|
||||||
|
// lived in one container's stderr on one machine. Every line is now an event of the role, retained
|
||||||
|
// with the rest of the mesh's events, so a reader follows a build live by subscribing its subject,
|
||||||
|
// or reads it back afterwards from the stream, and a viewer is a subscriber and nothing more.
|
||||||
|
func BuildStarted() string { return "mesh.seat." + TheBuildMachine + ".event.started" }
|
||||||
|
func BuildLog(id string) string { return "mesh.seat." + TheBuildMachine + ".event.log." + id }
|
||||||
|
|
||||||
|
// BuildStart is what a build machine says the moment it takes a build.
|
||||||
|
type BuildStart struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
|
On string `json:"on"`
|
||||||
|
At string `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildLine is one thing a build said while building.
|
||||||
|
type BuildLine struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Seq counts the lines of one build from 1, so a reader that joined late or read two copies
|
||||||
|
// can order them and see a gap.
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
At string `json:"at"`
|
||||||
|
// Step is which part of the build spoke — clone, context, image, run, failed — and Message is
|
||||||
|
// what it said, as the builder's own log prints it.
|
||||||
|
Step string `json:"step"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
// KeyRoleBuilt is the build outcome under the role's name, on the bus the mesh runs on today.
|
// KeyRoleBuilt is the build outcome under the role's name, on the bus the mesh runs on today.
|
||||||
//
|
//
|
||||||
// The same event as KeyModuleBuilt and published beside it, because a catalogue installed before this
|
// The same event as KeyModuleBuilt and published beside it, because a catalogue installed before this
|
||||||
@@ -44,6 +78,12 @@ type Builders interface {
|
|||||||
// need different remedies, which is why the message distinguishes them.
|
// need different remedies, which is why the message distinguishes them.
|
||||||
Submit(ctx context.Context, request BuildRequest, wait time.Duration) (BuildResult, error)
|
Submit(ctx context.Context, request BuildRequest, wait time.Duration) (BuildResult, error)
|
||||||
|
|
||||||
|
// Ask submits one build and does not wait: the outcome is the role's event, heard and taken in
|
||||||
|
// by the controller whether or not anybody waited (novox/hq issue 176). For a caller that
|
||||||
|
// cannot hold a connection for the minutes a build takes — a tool call — and follows the build
|
||||||
|
// by its id instead.
|
||||||
|
Ask(ctx context.Context, request BuildRequest) error
|
||||||
|
|
||||||
// Close lets go of whatever was dialled.
|
// Close lets go of whatever was dialled.
|
||||||
Close()
|
Close()
|
||||||
}
|
}
|
||||||
@@ -57,6 +97,13 @@ type BuildMachine interface {
|
|||||||
|
|
||||||
// Build is one request a machine has been handed.
|
// Build is one request a machine has been handed.
|
||||||
type Build interface {
|
type Build interface {
|
||||||
|
// Began says the build has been taken and is under way, before anything runs.
|
||||||
|
Began(ctx context.Context) error
|
||||||
|
|
||||||
|
// Say publishes one line of what the build is doing. Never fails the build: a line the bus
|
||||||
|
// did not take is a line lost, and the outcome still comes.
|
||||||
|
Say(step, message string)
|
||||||
|
|
||||||
// Request is what to build.
|
// Request is what to build.
|
||||||
Request() BuildRequest
|
Request() BuildRequest
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ func (b *natsBuilds) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ask publishes the work and returns; see Builders.
|
||||||
|
func (b *natsBuilds) Ask(ctx context.Context, request BuildRequest) error {
|
||||||
|
body, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
publish, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := b.js.Context().Publish(BuildWork(), body, nats.Context(publish)); err != nil {
|
||||||
|
return fmt.Errorf("cannot submit a build: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *natsBuilds) Submit(ctx context.Context, request BuildRequest,
|
func (b *natsBuilds) Submit(ctx context.Context, request BuildRequest,
|
||||||
wait time.Duration) (BuildResult, error) {
|
wait time.Duration) (BuildResult, error) {
|
||||||
|
|
||||||
@@ -169,6 +183,7 @@ type natsBuild struct {
|
|||||||
msg *nats.Msg
|
msg *nats.Msg
|
||||||
on string
|
on string
|
||||||
js *broker.JetStream
|
js *broker.JetStream
|
||||||
|
seq int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *natsBuild) Request() BuildRequest { return b.request }
|
func (b *natsBuild) Request() BuildRequest { return b.request }
|
||||||
@@ -203,4 +218,37 @@ func (b *natsBuild) Announce(ctx context.Context, result BuildResult) error {
|
|||||||
|
|
||||||
func (b *natsBuild) Done() error { return b.msg.Ack() }
|
func (b *natsBuild) Done() error { return b.msg.Ack() }
|
||||||
|
|
||||||
|
// Began publishes that this machine has taken the build, into the stream like the outcome, so a
|
||||||
|
// reader that asks afterwards sees when it started as well as how it ended.
|
||||||
|
func (b *natsBuild) Began(ctx context.Context) error {
|
||||||
|
body, err := json.Marshal(BuildStart{
|
||||||
|
ID: b.request.ID, Repository: b.request.Repository, Path: b.request.Path,
|
||||||
|
Ref: b.request.Ref, On: b.on, At: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
publish, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := b.js.Context().Publish(BuildStarted(), body, nats.Context(publish)); err != nil {
|
||||||
|
return fmt.Errorf("cannot say a build started: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Say publishes one line under the build's id. Core publish, unawaited: the stream that holds the
|
||||||
|
// role's events captures it on its way through, and a build must not slow to the pace of an ack
|
||||||
|
// per line. A line the bus did not take is counted anyway, so the gap is visible to a reader.
|
||||||
|
func (b *natsBuild) Say(step, message string) {
|
||||||
|
b.seq++
|
||||||
|
body, err := json.Marshal(BuildLine{
|
||||||
|
ID: b.request.ID, Seq: b.seq, At: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
|
Step: step, Message: message,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = b.js.Conn().Publish(BuildLog(b.request.ID), body)
|
||||||
|
}
|
||||||
|
|
||||||
func (b *natsBuild) Hold(after time.Duration) error { return b.msg.NakWithDelay(after) }
|
func (b *natsBuild) Hold(after time.Duration) error { return b.msg.NakWithDelay(after) }
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
|
|||||||
defer func() { _ = watching.Unsubscribe() }()
|
defer func() { _ = watching.Unsubscribe() }()
|
||||||
_ = js.Conn().Flush()
|
_ = js.Conn().Flush()
|
||||||
|
|
||||||
|
// And a reader following this one build by its subject alone (novox/hq ADR 0157).
|
||||||
|
lines, err := js.Conn().SubscribeSync(BuildLog("b-1"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = lines.Unsubscribe() }()
|
||||||
|
_ = js.Conn().Flush()
|
||||||
|
|
||||||
// A build machine holding the role.
|
// A build machine holding the role.
|
||||||
machine := MachineOverNATS(js, "anchor")
|
machine := MachineOverNATS(js, "anchor")
|
||||||
defer machine.Close()
|
defer machine.Close()
|
||||||
@@ -86,6 +94,9 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
failed <- machine.Take(ctx, func(ctx context.Context, work Build) {
|
failed <- machine.Take(ctx, func(ctx context.Context, work Build) {
|
||||||
r := work.Request()
|
r := work.Request()
|
||||||
|
_ = work.Began(ctx)
|
||||||
|
work.Say("clone", "cloning /r")
|
||||||
|
work.Say("image", "building shop")
|
||||||
_ = work.Announce(ctx, BuildResult{
|
_ = work.Announce(ctx, BuildResult{
|
||||||
ID: r.ID, Repository: r.Repository, On: "anchor", Commit: "abc1234",
|
ID: r.ID, Repository: r.Repository, On: "anchor", Commit: "abc1234",
|
||||||
Manifest: json.RawMessage(`{"module":"shop"}`),
|
Manifest: json.RawMessage(`{"module":"shop"}`),
|
||||||
@@ -104,6 +115,27 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Fatalf("the asker never got an outcome: %v", err)
|
t.Fatalf("the asker never got an outcome: %v", err)
|
||||||
}
|
}
|
||||||
|
// The reader heard the build as it went, in order, under its id.
|
||||||
|
for want := 1; want <= 2; want++ {
|
||||||
|
msg, err := lines.NextMsg(5 * time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("line %d of the build never reached its subject: %v", want, err)
|
||||||
|
}
|
||||||
|
var line BuildLine
|
||||||
|
if err := json.Unmarshal(msg.Data, &line); err != nil || line.ID != "b-1" || line.Seq != want {
|
||||||
|
t.Fatalf("line %d came back as %s (%v)", want, msg.Data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And it is in the stream for a reader who comes later.
|
||||||
|
info, err := js.Context().StreamInfo(broker.EventsStream, &nats.StreamInfoRequest{SubjectsFilter: BuildLog("b-1")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.State.Subjects[BuildLog("b-1")] != 2 {
|
||||||
|
t.Fatalf("the stream holds %v under the build's subject, want 2", info.State.Subjects)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
}
|
||||||
if result.ID != "b-1" || result.Commit != "abc1234" {
|
if result.ID != "b-1" || result.Commit != "abc1234" {
|
||||||
t.Fatalf("the asker got %+v", result)
|
t.Fatalf("the asker got %+v", result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A role's tools, served by its holder (novox/hq ADR 0132, ADR 0154).
|
||||||
|
//
|
||||||
|
// The mesh's own verbs — `status`, `push`, `assign` — are the mesh-controller seat's tools, and the
|
||||||
|
// control plane is that seat's holder. So it answers them here, on the seat's subjects, the way a
|
||||||
|
// module's runtime answers a module's: one request, one reply on the asker's own inbox, `{result}` or
|
||||||
|
// `{error}`. Nothing about the transport is the command's business; a handler is a function of its
|
||||||
|
// arguments and gets the same answer the command line prints.
|
||||||
|
|
||||||
|
// ToolHandler answers one call of a role's tool. What it returns is marshalled as the result; an
|
||||||
|
// error is the tool answering with one, which is an answer and not a timeout.
|
||||||
|
type ToolHandler func(ctx context.Context, args json.RawMessage) (any, error)
|
||||||
|
|
||||||
|
// SeatToolSubject is where a mesh-scoped seat's tool is asked (design 33 §4).
|
||||||
|
func SeatToolSubject(seat, verb string) string { return "mesh.seat." + seat + ".tool." + verb }
|
||||||
|
|
||||||
|
// HandlerTimeout bounds one answer. A verb that runs a command — a push, a build with no wait —
|
||||||
|
// answers in seconds; anything that has not in this long is said to have not answered.
|
||||||
|
const HandlerTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// RebindAfter is how long a refused subscription waits before it is tried again.
|
||||||
|
const RebindAfter = 30 * time.Second
|
||||||
|
|
||||||
|
// ServeSeatTools binds every handler on its seat's subject until stopped. A queue group per seat, so
|
||||||
|
// a second holder during a handover shares the calls rather than both answering one.
|
||||||
|
//
|
||||||
|
// **A holder binds when it may, not only when it starts.** The grant that lets the controller
|
||||||
|
// subscribe its seat's tools is a line in the bus's user list, and that list is composed by the
|
||||||
|
// controller and delivered to the broker's machine by a push — so the first controller to serve
|
||||||
|
// these started before the list named them, the server refused every subscription, and nothing
|
||||||
|
// tried again (2026-09-30). A refused subscription is therefore retried until it holds: the server
|
||||||
|
// says so asynchronously and invalidates the subscription, which is what is checked.
|
||||||
|
func (b OverNATS) ServeSeatTools(seat string, handlers map[string]ToolHandler, logger *log.Logger) (func(), error) {
|
||||||
|
var subs []*nats.Subscription
|
||||||
|
done := make(chan struct{})
|
||||||
|
stop := func() {
|
||||||
|
close(done)
|
||||||
|
for _, s := range subs {
|
||||||
|
_ = s.Unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for verb, handle := range handlers {
|
||||||
|
verb, handle := verb, handle
|
||||||
|
subject := SeatToolSubject(seat, verb)
|
||||||
|
bind := func() (*nats.Subscription, error) {
|
||||||
|
return b.Conn.QueueSubscribe(subject, "seat."+seat, func(msg *nats.Msg) {
|
||||||
|
// Its own goroutine per call: a slow `push` must not hold up a `status` asked beside it,
|
||||||
|
// and the library would otherwise run handlers one after another.
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), HandlerTimeout)
|
||||||
|
defer cancel()
|
||||||
|
args := json.RawMessage(msg.Data)
|
||||||
|
if len(args) == 0 {
|
||||||
|
args = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
var reply []byte
|
||||||
|
result, err := handle(ctx, args)
|
||||||
|
if err != nil {
|
||||||
|
reply, _ = json.Marshal(map[string]any{"error": err.Error()})
|
||||||
|
} else if reply, err = json.Marshal(map[string]any{"result": result}); err != nil {
|
||||||
|
reply, _ = json.Marshal(map[string]any{"error": "the answer could not be written as JSON: " + err.Error()})
|
||||||
|
}
|
||||||
|
if err := msg.Respond(reply); err != nil && logger != nil {
|
||||||
|
logger.Printf("%s: could not answer: %v", subject, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sub, err := bind()
|
||||||
|
if err != nil {
|
||||||
|
stop()
|
||||||
|
return nil, fmt.Errorf("serving %s: %w", subject, err)
|
||||||
|
}
|
||||||
|
subs = append(subs, sub)
|
||||||
|
go keepBound(sub, bind, subject, done, logger)
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Printf("serving %d tool(s) of the %s seat", len(handlers), seat)
|
||||||
|
}
|
||||||
|
return stop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepBound watches one subscription and re-binds it after the server refused it, until stopped.
|
||||||
|
// A subscription the server refused is invalid a moment after it was made; one it accepted stays
|
||||||
|
// valid. Checked rather than hooked, because the connection's error handler belongs to whoever
|
||||||
|
// dialled and a second one would replace it.
|
||||||
|
func keepBound(sub *nats.Subscription, bind func() (*nats.Subscription, error), subject string,
|
||||||
|
done <-chan struct{}, logger *log.Logger) {
|
||||||
|
current := sub
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
if current.IsValid() {
|
||||||
|
// Settled; from here a lost subscription is a lost connection, which the client
|
||||||
|
// restores itself with every subscription it holds.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Printf("%s: the bus refused the subscription; trying again in %s — the grant "+
|
||||||
|
"arrives with the next push to the machine holding mesh-broker", subject, RebindAfter)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-time.After(RebindAfter):
|
||||||
|
}
|
||||||
|
again, err := bind()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current = again
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,10 +169,12 @@ func (g *Generator) Graph() Graph { return g.graph }
|
|||||||
//
|
//
|
||||||
// - No floor: no header, no localhost, no `127.0.1.1` — those are the machine's, above the region.
|
// - No floor: no header, no localhost, no `127.0.1.1` — those are the machine's, above the region.
|
||||||
// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback.
|
// - A machine's own line is marked, and its mesh name resolves to its mesh address, not loopback.
|
||||||
// - `.Names` is every name the mesh serves (issue 111), so a container reaching a routed name
|
// - `.Names` is every name the mesh serves (issue 111), so anything on the machine reaching a
|
||||||
// finds the machine serving it; machines with no address yet are already left out of the set.
|
// routed name through its resolver finds the machine serving it; machines with no address yet
|
||||||
|
// are already left out of the set. A routed name is one alias, itself — a machine has a bare
|
||||||
|
// name beside its full one, a routed name has nothing beside it (issue 157).
|
||||||
const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" +
|
const hostsTemplate = "# The mesh's names. This region is replaced whenever a machine joins or leaves.\n" +
|
||||||
"{{range .Names}}{{.Address}}\t{{.FQDN}}\t{{.Name}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}"
|
"{{range .Names}}{{.Address}}\t{{.FQDN}}{{if ne .Name .FQDN}}\t{{.Name}}{{end}}{{if eq .Name $.Node}}\t# this machine{{end}}\n{{end}}"
|
||||||
|
|
||||||
// Manifest is the module the mesh provides for itself.
|
// Manifest is the module the mesh provides for itself.
|
||||||
//
|
//
|
||||||
|
|||||||
+14
@@ -28,6 +28,20 @@
|
|||||||
},
|
},
|
||||||
"secrets-owner": "65534:65534",
|
"secrets-owner": "65534:65534",
|
||||||
"prepares": true,
|
"prepares": true,
|
||||||
|
"tools": [
|
||||||
|
"tools",
|
||||||
|
"status",
|
||||||
|
"nodes",
|
||||||
|
"node",
|
||||||
|
"modules",
|
||||||
|
"seats",
|
||||||
|
"builds",
|
||||||
|
"plan",
|
||||||
|
"assign",
|
||||||
|
"unassign",
|
||||||
|
"push",
|
||||||
|
"build"
|
||||||
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
|
|||||||
Reference in New Issue
Block a user