diff --git a/internal/bootstrap/registry.go b/internal/bootstrap/registry.go index 966d612..8bc3803 100644 --- a/internal/bootstrap/registry.go +++ b/internal/bootstrap/registry.go @@ -9,10 +9,10 @@ import ( "time" ) -// RegistryModule is the module that provides the mesh's artifact store. +// RegistryModule is the module that holds the mesh's artifact store. // -// **Named for the software, not the job.** The job is `artifact-store`, which is the provision this -// module offers; the software is Distribution, the OCI reference implementation. Calling the module +// **Named for the software, not the job.** The job is the artifact store, which this module holds as +// the seat container-image-registry (novox/hq ADR 0632 §1); the software is Distribution, the OCI reference implementation. Calling the module // `registry` named neither — and promised that any registry could sit there, which is the false // genericity the naming rule forbids (novox/hq ADR 0040). const RegistryModule = "distribution" diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go index 59cb564..c66878b 100644 --- a/internal/compose/composed_test.go +++ b/internal/compose/composed_test.go @@ -109,7 +109,8 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'), Digest: dg('a'), Language: "go", Binary: "node-tools"}}} store := catalogue.Manifest{Module: "artifacts", Version: "1", - Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}, + Listens: []catalogue.Listening{{Name: "registry", Port: 5100, From: catalogue.FromMesh}}} lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}}, Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle, diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go index 5954cfc..05a7fe9 100644 --- a/internal/compose/frombus/answer_rows_test.go +++ b/internal/compose/frombus/answer_rows_test.go @@ -606,13 +606,15 @@ func otherNodeNotResolvedIsNamed(t *testing.T) { // "reaches no artifact store", and nothing said why). func noStoreReachedSaysWhy(t *testing.T) { in := compose.Input{Catalogue: map[string]catalogue.Manifest{ - "distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}}, - Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}} - if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" { + "distribution": {Module: "distribution", Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}}, + // A module serving the retired provision artifact-store is no store (ADR 0632 §1). + "old-store": {Module: "old-store", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}}, + Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita", "old-store"}}, {Name: "novox", Assigned: []string{"distribution"}}}} + if got := storeSought(in); got != "the composition holds g14, novox; container-image-registry is held by distribution on novox, none of it on the private network with a listen open to the mesh" { t.Errorf("said %q", got) } in.Nodes = in.Nodes[:1] - if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" { + if got := storeSought(in); got != "the composition holds g14; no module placed there holds container-image-registry" { t.Errorf("said %q", got) } } diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go index 49292c5..f4c1d20 100644 --- a/internal/compose/frombus/frombus.go +++ b/internal/compose/frombus/frombus.go @@ -1136,16 +1136,14 @@ func sortedKeys[V any](m map[string]V) []string { } // storeSought says, for a node that reaches no artifact store, what the composition holds of one: the nodes it holds and -// where each module serving the artifact store is placed, so the refusal names why (issue 1126). +// where each module claiming the store's seat is placed, so the refusal names why (issue 1126, ADR 0632 §1). func storeSought(in compose.Input) string { var nodes, where []string for _, n := range in.Nodes { nodes = append(nodes, n.Name) for _, a := range n.Assigned { - if m, ok := in.Catalogue[a]; ok { - if _, serves := m.Serves[catalogue.ArtifactStoreProvision]; serves { - where = append(where, a+" on "+n.Name) - } + if m, ok := in.Catalogue[a]; ok && claimsSeat(m, compose.StoreSeat) { + where = append(where, a+" on "+n.Name) } } } @@ -1153,10 +1151,20 @@ func storeSought(in compose.Input) string { sort.Strings(where) said := "the composition holds " + strings.Join(nodes, ", ") if len(where) == 0 { - return said + "; no module placed there serves " + catalogue.ArtifactStoreProvision + return said + "; no module placed there holds " + compose.StoreSeat } - return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") + - ", none of it on the private network with a port" + return said + "; " + compose.StoreSeat + " is held by " + strings.Join(where, ", ") + + ", none of it on the private network with a listen open to the mesh" +} + +// claimsSeat says whether m claims seat at the mesh's scope. +func claimsSeat(m catalogue.Manifest, seat string) bool { + for _, cl := range m.Claims { + if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == seat && cl.At() == catalogue.ScopeMesh { + return true + } + } + return false } // keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil. diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go index 7e030c1..3f4e889 100644 --- a/internal/compose/frombus/frombus_test.go +++ b/internal/compose/frombus/frombus_test.go @@ -577,8 +577,8 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } - store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + - `"serves":{"artifact-store":{"port":5000}}}`) + store := []byte(`{"module":"store","version":"1",` + + `"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`) texts := map[string]string{} var packed bytes.Buffer zw := gzip.NewWriter(&packed) diff --git a/internal/compose/frombus/frombustest/frombustest.go b/internal/compose/frombus/frombustest/frombustest.go index a627e30..5b00ed6 100644 --- a/internal/compose/frombus/frombustest/frombustest.go +++ b/internal/compose/frombus/frombustest/frombustest.go @@ -119,8 +119,8 @@ func Provisioned() (*Bus, error) { if err != nil { return nil, err } - store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + - `"serves":{"artifact-store":{"port":5000}}}`) + store := []byte(`{"module":"store","version":"1",` + + `"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`) postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` + `"serves":{"postgres-database":{"port":5432}}}`) diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go index ef66cee..46cd176 100644 --- a/internal/compose/frombus/held_rows_test.go +++ b/internal/compose/frombus/held_rows_test.go @@ -119,7 +119,7 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) { tools := `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` - store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + + store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5100,"from":"mesh"}],` + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64) running := []delivery.Composed{{Node: "n1", Module: "node-tools", FP: old, Resources: []json.RawMessage{ @@ -215,7 +215,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}` } lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` - store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + + store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5100,"from":"mesh"}],` + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` for _, row := range []struct { name string diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go index 770db5e..f6c0bb4 100644 --- a/internal/compose/rendering.go +++ b/internal/compose/rendering.go @@ -167,10 +167,10 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222). if store != "" { - // Both names the store is reached through answer the one address (ENG-3), each with its holder from the holdings on - // record, written beside every image routed through it (design 51, ENG-10). + // Every name the store is reached through answers the one address (ENG-3, ADR 0632 §1), each with its holder from the + // holdings on record, written beside every image routed through it (design 51, ENG-10). r.SeatReach, r.SeatHolder = map[string]string{}, map[string]string{} - for _, seat := range []string{"mesh-artifact-store", "container-image-registry"} { + for _, seat := range resolution.StoreSeats { r.SeatReach[seat] = store for _, h := range c.in.Holdings { if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat && h.Scope == catalogue.ScopeMesh && h.Module != "" { @@ -326,18 +326,67 @@ func (c *composer) namesInTheMesh() map[string]string { return out } -// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on. -func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) { - providers := map[string]catalogue.Manifest{} - for name, m := range c.in.Catalogue { - if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers { - providers[name] = m +// StoreSeat is the seat whose holder is the artifact store (novox/hq ADR 0632 §1): the store's address is that holder's +// listen open to the mesh, on the machine placement puts it on. The provision artifact-store it was found by before is +// retired; no module serves it. +const StoreSeat = "container-image-registry" + +// storeHolders are the modules holding the store's seat: the holdings on record, and only where none is on record every +// module of the catalogue claiming it at the mesh's scope, as a seat holder's reach is read (replicatedHolders). +func (c *composer) storeHolders() map[string]catalogue.Manifest { + out := map[string]catalogue.Manifest{} + for _, h := range c.in.Holdings { + if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == StoreSeat && h.Scope == catalogue.ScopeMesh && h.Module != "" { + if m, known := c.in.Catalogue[h.Module]; known { + out[h.Module] = m + } } } - if len(providers) == 0 { + if len(out) > 0 { + return out + } + for name, m := range c.in.Catalogue { + for _, cl := range m.Claims { + if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == StoreSeat && cl.At() == catalogue.ScopeMesh { + out[name] = m + } + } + } + return out +} + +// storeListen is the store holder's listen every machine pulls from: its one listen open to the mesh. A holder opening +// none, or more than one, says no address, by name: which of two ports is the store is no guess the composer makes. +func storeListen(m catalogue.Manifest) (catalogue.Listening, error) { + var open []catalogue.Listening + for _, l := range m.Listens { + if l.From == catalogue.FromMesh && l.At() == "tcp" { + open = append(open, l) + } + } + switch len(open) { + case 1: + return open[0], nil + case 0: + return catalogue.Listening{}, fmt.Errorf("%s holds %s and opens no tcp listen to the mesh: the artifact store has no "+ + "address (novox/hq ADR 0632 §1)", m.Module, StoreSeat) + } + var said []string + for _, l := range open { + said = append(said, fmt.Sprintf("%s (%d)", l.Name, l.Port)) + } + return catalogue.Listening{}, fmt.Errorf("%s holds %s and opens %s to the mesh: the artifact store's address is its one "+ + "listen open to the mesh (novox/hq ADR 0632 §1)", m.Module, StoreSeat, strings.Join(said, ", ")) +} + +// artifactStoreOnNetwork is the machine on this network that holds the store's seat, and the port that machine put the +// holder's listen on (novox/hq ADR 0632 §1). +func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) { + holders := c.storeHolders() + if len(holders) == 0 { return "", "", false, nil } - // In a stated order, so two machines offering it would always answer the same one. + // In a stated order, so two machines holding it would always answer the same one. machines := make([]string, 0, len(on)) for machine := range on { machines = append(machines, machine) @@ -349,17 +398,23 @@ func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err) } for _, a := range n.Assigned { - m, offers := providers[a] - if !offers { + m, holds := holders[a] + if !holds { continue } - serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision) + l, err := storeListen(m) + if err != nil { + return "", "", false, err + } + ports, _, err := c.portsGivenOn(machine, m) if err != nil { return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err) } - if p, ok := serves["port"]; ok { - return machine, fmt.Sprintf("%v", p), true, nil + at := l.Port + if given, ok := ports[l.Port]; ok { + at = given } + return machine, fmt.Sprintf("%d", at), true, nil } } return "", "", false, nil diff --git a/internal/compose/rendering_test.go b/internal/compose/rendering_test.go index f8d1448..0489a5e 100644 --- a/internal/compose/rendering_test.go +++ b/internal/compose/rendering_test.go @@ -254,7 +254,8 @@ func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) { func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) { in := aMesh(t) in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1", - Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}, + Listens: []catalogue.Listening{{Name: "registry", Port: 5100, From: catalogue.FromMesh}}} anchor := nodeOf(t, &in, "anchor") anchor.Assigned = append(anchor.Assigned, "artifacts") if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" { @@ -290,6 +291,41 @@ func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *test } } +// The store's address is the image seat's holder's listen open to the mesh, on the port its node put it on, and every +// name of the store answers it (novox/hq ADR 0632 §1): a module serving the retired provision artifact-store is no store, +// and a holder opening two listens to the mesh says which is the store by no guess. +func TestTheStoreIsFoundByTheImageSeatsHolderAndItsListen(t *testing.T) { + in := aMesh(t) + in.Catalogue["old"] = catalogue.Manifest{Module: "old", Version: "1", + Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "old") + if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" { + t.Errorf("a module serving artifact-store is reached as the store at %q", got) + } + in.Catalogue["registry"] = catalogue.Manifest{Module: "registry", Version: "1", + Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}, + Listens: []catalogue.Listening{{Name: "local", Port: 5001, From: catalogue.FromMachine}, + {Name: "registry", Port: 5000, From: catalogue.FromMesh}}} + anchor.Assigned = append(anchor.Assigned, "registry") + anchor.Ports = map[string]map[int]int{"registry": {5000: 5200}} + r := placed(t, in, "laptop").Rendering + if r.ArtifactStore != "anchor.internal:5200" { + t.Errorf("laptop reaches the store at %q, want the holder's listen open to the mesh on anchor's port", r.ArtifactStore) + } + for _, seat := range []string{"mesh-artifact-store", "container-image-registry", "oci-artifact-registry"} { + if r.SeatReach[seat] != "anchor.internal:5200" { + t.Errorf("${seat:%s:reach} is %q", seat, r.SeatReach[seat]) + } + } + m := in.Catalogue["registry"] + m.Listens = append(m.Listens, catalogue.Listening{Name: "other", Port: 5300, From: catalogue.FromMesh}) + in.Catalogue["registry"] = m + if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "opens registry (5000), other (5300) to the mesh") { + t.Errorf("a holder opening two listens to the mesh: %v", err) + } +} + // A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on // the port the answering listen is published on there (novox/hq ADR 0199); the same zone declared twice is refused, by name. func TestAZoneIsPlacedWhereTheMeshPutItAndTheSameZoneTwiceIsRefused(t *testing.T) { diff --git a/internal/compose/resolution/artifacts.go b/internal/compose/resolution/artifacts.go index be3ce85..67aad4b 100644 --- a/internal/compose/resolution/artifacts.go +++ b/internal/compose/resolution/artifacts.go @@ -36,9 +36,9 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering) if with.ArtifactStore == "" { return fmt.Errorf( "%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+ - "artifact store on its network to fetch it from — nothing assigned offers "+ - "%s, or the machine offering it is not on the private network", - module, resource["id"], written, mcat.ArtifactStoreProvision) + "artifact store on its network to fetch it from — nothing assigned holds the seat "+ + "container-image-registry, or the machine holding it is not on the private network", + module, resource["id"], written) } resource[key] = mcat.Routed(written, with.ArtifactStore) imageSeatInto(resource, key, written, with) diff --git a/internal/compose/resolution/seat_into.go b/internal/compose/resolution/seat_into.go index c78ec5c..53e26a6 100644 --- a/internal/compose/resolution/seat_into.go +++ b/internal/compose/resolution/seat_into.go @@ -34,9 +34,18 @@ var ofSeatReach = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):reach\}`) // reachedSeats is every seat ${seat:…:reach} answers for. // -// Both names of the store are answered with one address (ADR 0605, issue 1966 ENG-3): the seat it was held through and the -// seat the registries' holder serves the same port as. -var reachedSeats = map[string]bool{"mesh-artifact-store": true, "container-image-registry": true} +// Every name of the store is answered with one address (ADR 0605, issue 1966 ENG-3, ADR 0632 §1): the seat it was held +// through and the two registry seats its holder serves on the same port, the image seat's holder's listen open to the mesh. +var reachedSeats = map[string]bool{} + +// StoreSeats are the seats the artifact store is reached through, each answered with the one address. +var StoreSeats = []string{"mesh-artifact-store", "container-image-registry", "oci-artifact-registry"} + +func init() { + for _, s := range StoreSeats { + reachedSeats[s] = 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 diff --git a/internal/compose/resolution/seat_into_test.go b/internal/compose/resolution/seat_into_test.go index ddb7246..41e3a08 100644 --- a/internal/compose/resolution/seat_into_test.go +++ b/internal/compose/resolution/seat_into_test.go @@ -111,15 +111,18 @@ func TestASeatsReachIsWhereThisMachineReachesItsHolder(t *testing.T) { t.Fatalf("an environment value was filled with %q", got) } - // And the seat the registries' holder serves the same port through answers the same address (ENG-3). + // And the seats the registries' holder serves the same port through answer the same address (ENG-3, ADR 0632 §1). both := map[string]any{"type": "process", "name": "p", "env": map[string]any{ - "OLD": "${seat:mesh-artifact-store:reach}", "NEW": "${seat:container-image-registry:reach}"}} + "OLD": "${seat:mesh-artifact-store:reach}", "NEW": "${seat:container-image-registry:reach}", + "OCI": "${seat:oci-artifact-registry:reach}"}} with.SeatReach["container-image-registry"] = "anchor.internal:5100" + with.SeatReach["oci-artifact-registry"] = "anchor.internal:5100" if err := seatInto(both, "x", with); err != nil { t.Fatal(err) } - if env := both["env"].(map[string]any); env["OLD"] != "anchor.internal:5100" || env["NEW"] != "anchor.internal:5100" { - t.Fatalf("the two names of the store answer %v", env) + if env := both["env"].(map[string]any); env["OLD"] != "anchor.internal:5100" || env["NEW"] != "anchor.internal:5100" || + env["OCI"] != "anchor.internal:5100" { + t.Fatalf("the three names of the store answer %v", env) } } diff --git a/internal/link/have_test.go b/internal/link/have_test.go index 93916f9..47a3061 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -286,6 +286,10 @@ func TestTheNodeKeyCarriesTheNodesFacts(t *testing.T) { if !delivery.SameNodeRecord(*got.Record, wantRecord) { t.Errorf("record = %+v, want %+v", *got.Record, wantRecord) } + // Among them, that it fills a seat's reach from the seat holder's listen (ADR 0632 §1). + if !slices.Contains(got.Record.ManifestReads, catalogue.ReadForm+catalogue.ReferenceFormSeatReach) { + t.Errorf("the engine does not state it reads a seat's reach from its holder: %v", got.Record.ManifestReads) + } at := l.kv["n1.node"].rev q.SayFacts(context.Background(), rep) if l.kv["n1.node"].rev != at {