diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 2c66137..94c66f8 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -732,6 +732,13 @@ func renderingFor(ctx context.Context, open *stores, node string, if err != nil { return catalogue.Rendering{}, inventory.Node{}, err } + // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR + // 0222): the artifact store as this network reaches it, which the container runtime is told to + // trust (ADR 0082). The same address composed into every reference the mesh built. + var reach map[string]string + if artifactStore != "" { + reach = map[string]string{"mesh-artifact-store": artifactStore} + } return catalogue.Rendering{ BusMembership: memberships[node], Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports, @@ -739,7 +746,7 @@ func renderingFor(ctx context.Context, open *stores, node string, Machines: machines, Zones: zones, Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation, Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks, - Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built, + Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built, BusUsers: busUsers, }, record, nil } diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index fb64418..86204a4 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -201,6 +201,11 @@ type Rendering struct { // stored (novox/hq 04-ISSUES/102). ArtifactStore string + // SeatReach is where this machine reaches the holder of each mesh-scoped seat it may be asked + // about (host:port), by seat: what ${seat::reach} answers with (novox/hq ADR 0222). Absent + // when no holder is reachable yet; see seat_into.go for which seats are answered. + SeatReach map[string]string + // Built is every `/` the mesh has built. What tells a reference recorded // with an address — before references were kept without one — from an image a module runs // straight from a public registry. diff --git a/internal/catalogue/seat_into.go b/internal/catalogue/seat_into.go index a38909a..ea7c833 100644 --- a/internal/catalogue/seat_into.go +++ b/internal/catalogue/seat_into.go @@ -1,6 +1,7 @@ package catalogue import ( + "encoding/json" "fmt" "regexp" "sort" @@ -42,6 +43,27 @@ import ( // ofSeat is where a module asks about a seat: ${seat::}. var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`) +// **Where this machine reaches a mesh seat's holder** (novox/hq ADR 0222, issue 190). +// +// `${seat::reach}` is host:port — the address this machine dials to reach whatever holds a +// seat the mesh holds once. The same reasoning as the port above, one step further: nothing is +// required, nothing is granted, no credential is minted, and the answer is an address the mesh +// already holds in the clear and composes into every reference it built. What it is for is a module +// that must *state* where a mesh service is to software it owns — the container runtime trusting +// the mesh's registry is the case — without becoming that service's consumer. +// +// Answered for the seats in reachedSeats only. Another seat is refused by name rather than answered +// with nothing: a module asking where something is that the mesh does not say would otherwise be +// given an empty value and never know the question was not understood. +// +// The answer may be empty: no machine on the private network holds the seat yet (genesis raises +// the store before the network). In a file written into as JSON, an empty member is dropped from +// its list, and a list left with none is dropped, so the runtime is never told to trust "". +var ofSeatReach = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):reach\}`) + +// reachedSeats is every seat ${seat:…:reach} answers for. +var reachedSeats = map[string]bool{"mesh-artifact-store": true} + // seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's // holder — in a file's content, and in a value of a container's or a process's environment. The // same places portInto fills, for the same reason: they are where a program reads a number from. @@ -49,13 +71,24 @@ func seatInto(resource map[string]any, module string, with Rendering) error { switch fmt.Sprint(resource["type"]) { case "file": content, ok := resource["content"].(string) - if !ok || !ofSeat.MatchString(content) { + if !ok || (!ofSeat.MatchString(content) && !ofSeatReach.MatchString(content)) { return nil } - filled, err := seatsFilledInto(content, fmt.Sprintf("%s has a file that", module), with) + where := fmt.Sprintf("%s has a file that", module) + filled, err := seatsFilledInto(content, where, with) if err != nil { return err } + if ofSeatReach.MatchString(filled) { + if filled, err = reachFilledInto(filled, where, with); err != nil { + return err + } + if fmt.Sprint(resource["into"]) == "json" { + if filled, err = withoutEmptyMembers(filled, where); err != nil { + return err + } + } + } resource["content"] = filled case "container", "process": @@ -74,15 +107,18 @@ func seatInto(resource map[string]any, module string, with Rendering) error { var filled map[string]any for _, key := range named { written, ok := env[key].(string) - if !ok || !ofSeat.MatchString(written) { + if !ok || (!ofSeat.MatchString(written) && !ofSeatReach.MatchString(written)) { continue } - value, err := seatsFilledInto(written, - fmt.Sprintf("%s's %s %s sets %s to something that", - module, resource["type"], resource["name"], key), with) + where := fmt.Sprintf("%s's %s %s sets %s to something that", + module, resource["type"], resource["name"], key) + value, err := seatsFilledInto(written, where, with) if err != nil { return err } + if value, err = reachFilledInto(value, where, with); err != nil { + return err + } if filled == nil { filled = map[string]any{} for k, v := range env { @@ -118,3 +154,69 @@ func seatsFilledInto(written, where string, with Rendering) (string, error) { } return written, nil } + +// reachFilledInto answers every ${seat:…:reach} in one written value with where this machine +// reaches the seat's holder, or with nothing when no holder is reachable yet. A seat the mesh does +// not answer this for is refused by name. +func reachFilledInto(written, where string, with Rendering) (string, error) { + for _, m := range ofSeatReach.FindAllStringSubmatch(written, -1) { + seat := m[1] + if !reachedSeats[seat] { + known := make([]string, 0, len(reachedSeats)) + for s := range reachedSeats { + known = append(known, s) + } + sort.Strings(known) + return "", fmt.Errorf("%s says ${seat:%s:reach}, and the mesh says where a seat's holder is "+ + "reached only for %s (novox/hq ADR 0222)", where, seat, strings.Join(known, ", ")) + } + written = strings.ReplaceAll(written, m[0], with.SeatReach[seat]) + } + return written, nil +} + +// withoutEmptyMembers drops every empty string from the lists at the top of a JSON object written +// into a machine's file, and a list left with no members, so an unanswered ${seat:…:reach} adds +// nothing to the machine's list rather than adding "". +func withoutEmptyMembers(content, where string) (string, error) { + var object map[string]json.RawMessage + if err := json.Unmarshal([]byte(content), &object); err != nil { + return "", fmt.Errorf("%s is written into as JSON and is not a JSON object: %w", where, err) + } + changed := false + for key, raw := range object { + var members []json.RawMessage + if err := json.Unmarshal(raw, &members); err != nil { + continue // not a list + } + kept := make([]json.RawMessage, 0, len(members)) + for _, member := range members { + var s string + if json.Unmarshal(member, &s) == nil && s == "" { + continue + } + kept = append(kept, member) + } + if len(kept) == len(members) { + continue + } + changed = true + if len(kept) == 0 { + delete(object, key) + continue + } + list, err := json.Marshal(kept) + if err != nil { + return "", err + } + object[key] = list + } + if !changed { + return content, nil + } + out, err := json.Marshal(object) + if err != nil { + return "", err + } + return string(out) + "\n", nil +} diff --git a/internal/catalogue/seat_into_test.go b/internal/catalogue/seat_into_test.go index 7cffab7..05aae7c 100644 --- a/internal/catalogue/seat_into_test.go +++ b/internal/catalogue/seat_into_test.go @@ -1,6 +1,7 @@ package catalogue import ( + "fmt" "os" "strings" "testing" @@ -211,3 +212,83 @@ func withSeatPorts(m Manifest) Manifest { } return out } + +// Where this machine reaches a mesh seat's holder (novox/hq ADR 0222, issue 190): the container +// runtime's module tells the runtime to trust the mesh's registry without binding the store. + +func runtimeTrust() map[string]any { + return map[string]any{ + "type": "file", "id": "daemon", "path": "/etc/docker/daemon.json", "into": "json", + "content": `{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"]}` + "\n", + } +} + +func TestASeatsReachIsWhereThisMachineReachesItsHolder(t *testing.T) { + file := runtimeTrust() + with := Rendering{SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"}} + if err := seatInto(file, "docker", with); err != nil { + t.Fatal(err) + } + want := `{"live-restore": true, "insecure-registries": ["anchor.internal:5100"]}` + "\n" + if file["content"] != want { + t.Fatalf("content = %q, want %q", file["content"], want) + } + + process := map[string]any{"type": "process", "name": "p", + "env": map[string]any{"REGISTRY": "${seat:mesh-artifact-store:reach}"}} + if err := seatInto(process, "x", with); err != nil { + t.Fatal(err) + } + if got := process["env"].(map[string]any)["REGISTRY"]; got != "anchor.internal:5100" { + t.Fatalf("an environment value was filled with %q", got) + } +} + +// With no holder reachable, the runtime is not told to trust "": the member is dropped, and the +// list with it when nothing else is in it. +func TestAnUnansweredReachAddsNothingToAList(t *testing.T) { + file := runtimeTrust() + if err := seatInto(file, "docker", Rendering{}); err != nil { + t.Fatal(err) + } + if got := file["content"]; got != `{"live-restore":true}`+"\n" { + t.Fatalf("with no store reachable, the runtime's keys are %q", got) + } + + kept := map[string]any{"type": "file", "into": "json", + "content": `{"insecure-registries": ["${seat:mesh-artifact-store:reach}", "192.0.2.7:5000"]}`} + if err := seatInto(kept, "docker", Rendering{}); err != nil { + t.Fatal(err) + } + if got := kept["content"]; got != `{"insecure-registries":["192.0.2.7:5000"]}`+"\n" { + t.Fatalf("a list's other members were not kept: %q", got) + } +} + +func TestAReachTheMeshDoesNotAnswerIsRefused(t *testing.T) { + file := map[string]any{"type": "file", "content": "${seat:mesh-store:reach}"} + err := seatInto(file, "x", Rendering{SeatReach: map[string]string{"mesh-store": "anchor.internal:5432"}}) + if err == nil || !strings.Contains(err.Error(), "mesh-artifact-store") { + t.Fatalf("a reach the mesh does not answer was not refused by name: %v", err) + } +} + +// Through the whole composition, as the container runtime's module declares it. +func TestTheRuntimesTrustIsComposedFromTheSeatsReach(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{{ + Module: "docker", Resources: []map[string]any{runtimeTrust()}, + }}} + out, err := r.Declaration(Rendering{ + SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"}, + }) + if err != nil { + t.Fatal(err) + } + file := fileNamed(out, "docker.daemon") + if file == nil { + t.Fatalf("no file in %v", out) + } + if got := file["content"]; !strings.Contains(fmt.Sprint(got), `["anchor.internal:5100"]`) { + t.Fatalf("the runtime's file says %q", got) + } +}