An address is read from the node's settings where it is used, never recorded with a port (hq issue 102) #50
@@ -0,0 +1,335 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/licences"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An address is read from the node's settings where it is used, never recorded with a port
|
||||||
|
// (novox/hq 04-ISSUES/102). Three readers did not follow the setting; each is held to it here.
|
||||||
|
|
||||||
|
var aDigest = "sha256:" + strings.Repeat("e", 64)
|
||||||
|
|
||||||
|
// **A build is recorded by digest and path**, whatever address the builder pushed to — and only
|
||||||
|
// what the build made is rewritten: an image the module runs from elsewhere is left where it says.
|
||||||
|
func TestABuildIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
||||||
|
manifest, _ := json.Marshal(map[string]any{
|
||||||
|
"module": "gitea", "version": "1",
|
||||||
|
"resources": []map[string]any{
|
||||||
|
{"id": "server", "type": "container", "name": "mesh-gitea",
|
||||||
|
"image": "anchor.internal:5100/gitea/server@" + aDigest},
|
||||||
|
{"id": "config", "type": "archive", "path": "/etc/gitea", "digest": aDigest,
|
||||||
|
"source": "http://anchor.internal:5100/v2/gitea/config/blobs/" + aDigest},
|
||||||
|
{"id": "cache", "type": "container", "name": "mesh-gitea-cache",
|
||||||
|
"image": "valkey/valkey@" + aDigest},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
kept := buildFrom(link.BuildResult{
|
||||||
|
ID: "b1", Repository: "https://forge.example/gitea.git", Commit: "abc", On: "laptop",
|
||||||
|
Manifest: manifest,
|
||||||
|
Made: []link.MadeArtifact{
|
||||||
|
{Name: "server", Kind: "image", Reference: "anchor.internal:5100/gitea/server@" + aDigest},
|
||||||
|
{Name: "config", Kind: "archive", Reference: "http://anchor.internal:5100/v2/gitea/config/blobs/" + aDigest},
|
||||||
|
},
|
||||||
|
Against: []string{"anchor.internal:5100/mesh-tools/runtime@" + aDigest},
|
||||||
|
})
|
||||||
|
if kept.Module != "gitea" {
|
||||||
|
t.Fatalf("the module was not read from the recorded manifest: %q", kept.Module)
|
||||||
|
}
|
||||||
|
if kept.Made[0].Reference != catalogue.ArtifactStoreScheme+"gitea/server@"+aDigest {
|
||||||
|
t.Errorf("the image is recorded as %q, address and all", kept.Made[0].Reference)
|
||||||
|
}
|
||||||
|
if kept.Made[1].Reference != catalogue.ArtifactStoreScheme+"gitea/config/blobs/"+aDigest {
|
||||||
|
t.Errorf("the archive is recorded as %q, address and all", kept.Made[1].Reference)
|
||||||
|
}
|
||||||
|
recorded, err := catalogue.ParseManifest(kept.Manifest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := recorded.Resources[0]["image"]; got != catalogue.ArtifactStoreScheme+"gitea/server@"+aDigest {
|
||||||
|
t.Errorf("the recorded manifest's image is %v", got)
|
||||||
|
}
|
||||||
|
if got := recorded.Resources[1]["source"]; got != catalogue.ArtifactStoreScheme+"gitea/config/blobs/"+aDigest {
|
||||||
|
t.Errorf("the recorded manifest's archive is %v", got)
|
||||||
|
}
|
||||||
|
if got := recorded.Resources[2]["image"]; got != "valkey/valkey@"+aDigest {
|
||||||
|
t.Errorf("an image the build did not make was rewritten: %v", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(kept.Manifest), "anchor.internal:5100") {
|
||||||
|
t.Errorf("the recorded manifest still carries the store's address:\n%s", kept.Manifest)
|
||||||
|
}
|
||||||
|
if kept.Against[0] != "anchor.internal:5100/mesh-tools/runtime@"+aDigest {
|
||||||
|
t.Errorf("what the build stood on was rewritten: %v", kept.Against)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aStore is a module offering the artifact store on 5000, published the long way as the
|
||||||
|
// distribution module does, so a node may be given another number for it.
|
||||||
|
func aStore() catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: "distribution", Version: "1",
|
||||||
|
Provides: []catalogue.Offer{{Name: catalogue.ArtifactStoreProvision, Scope: catalogue.ScopeMesh}},
|
||||||
|
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": float64(5000)}},
|
||||||
|
Listens: []catalogue.Listening{{Port: 5000, From: catalogue.FromMesh}},
|
||||||
|
Resources: []map[string]any{{"id": "store", "type": "container", "name": "mesh-registry",
|
||||||
|
"ports": []any{"5000:5000"}, "image": "registry@" + aDigest}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The trust a machine writes for the store, and the address every built image is fetched
|
||||||
|
// through, say the port the node gave the store** — not the catalogue's number.
|
||||||
|
func TestTheRegistryTrustAndEveryImageFollowThePortTheNodeGaveTheStore(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, aStore())
|
||||||
|
if _, err := assign(ctx, open, "anchor", "distribution"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := open.inventory.SetSettings(ctx, "anchor", "distribution",
|
||||||
|
map[string]any{catalogue.PortsSetting: map[string]any{"5000": 5101}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A module the mesh built, recorded by digest and path, running on the other machine.
|
||||||
|
if err := open.inventory.RecordBuild(ctx, inventory.Build{
|
||||||
|
ID: "b1", Repository: "r", Module: "app", Commit: "abc",
|
||||||
|
Made: []inventory.Artifact{{Name: "server", Kind: "image",
|
||||||
|
Reference: catalogue.ArtifactStoreScheme + "app/server@" + aDigest}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
register(t, open, catalogue.Manifest{Module: "app", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-app",
|
||||||
|
"image": catalogue.ArtifactStoreScheme + "app/server@" + aDigest}}})
|
||||||
|
if _, err := assign(ctx, open, "laptop", "app"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
on := map[string]bool{"anchor": true, "laptop": true}
|
||||||
|
node, port, found, err := artifactStoreOnNetwork(ctx, open.inventory, on)
|
||||||
|
if err != nil || !found || node != "anchor" || port != "5101" {
|
||||||
|
t.Fatalf("the store is found on %q:%q (%v, %v); the node put it on 5101", node, port, found, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var trust string
|
||||||
|
for _, r := range composed(t, open, "laptop").Resources {
|
||||||
|
if r["path"] == "/etc/docker/daemon.json" {
|
||||||
|
trust, _ = r["content"].(string)
|
||||||
|
}
|
||||||
|
if r["id"] == "app.server" && r["image"] != "anchor.internal:5101/app/server@"+aDigest {
|
||||||
|
t.Errorf("the image the mesh built is fetched as %v", r["image"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(trust, "anchor.internal:5101") || strings.Contains(trust, ":5000") {
|
||||||
|
t.Fatalf("the runtime is told to trust %q; the node put the store on 5101", trust)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a replay to the catalogue says where the store is now.
|
||||||
|
announced, err := following{open}.Announceable(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(announced) != 1 || announced[0].Made[0].Reference != "anchor.internal:5101/app/server@"+aDigest {
|
||||||
|
t.Fatalf("the replay announces %+v", announced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The control plane's own connections say the port the node gave the store and the broker.**
|
||||||
|
//
|
||||||
|
// Composed from the control plane's own manifest against a real inventory: the store's module is
|
||||||
|
// given 6852 on this node the way genesis or an operator gives it, and the control plane's
|
||||||
|
// container is told so beside the sealed connection genesis wrote.
|
||||||
|
func TestTheControlPlaneIsToldWhereTheNodePutTheStoreAndTheBroker(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err := catalogue.ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
control, err := withSeatPorts(m).Resolve([]catalogue.Built{{Name: "server", Kind: catalogue.ArtifactImage,
|
||||||
|
Reference: "registry.example/control@" + aDigest}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
register(t, open, control)
|
||||||
|
register(t, open, catalogue.Manifest{Module: "postgres", Version: "1",
|
||||||
|
Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}},
|
||||||
|
Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}},
|
||||||
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
|
||||||
|
"ports": []any{"5432:5432"}, "image": "pg@" + aDigest}}})
|
||||||
|
register(t, open, catalogue.Manifest{Module: "lavinmq", Version: "1",
|
||||||
|
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}},
|
||||||
|
Listens: []catalogue.Listening{{Port: 5671, From: catalogue.FromMesh},
|
||||||
|
{Port: 5672, From: catalogue.FromMesh}},
|
||||||
|
Guards: []int{15672},
|
||||||
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
|
||||||
|
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}, "image": "mq@" + aDigest}}})
|
||||||
|
if _, err := assign(ctx, open, "anchor", "mesh-controller"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The store's module is registered and given its port, and NOT assigned: the state genesis
|
||||||
|
// leaves a given-port node in before the foundation is adopted as modules (04-ISSUES/085).
|
||||||
|
if err := open.inventory.SetSettings(ctx, "anchor", "postgres",
|
||||||
|
map[string]any{catalogue.PortsSetting: map[string]any{"5432": 6852}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := assign(ctx, open, "anchor", "lavinmq"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := open.inventory.SetSettings(ctx, "anchor", "lavinmq",
|
||||||
|
map[string]any{catalogue.PortsSetting: map[string]any{"5672": 5679}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var env map[string]any
|
||||||
|
for _, r := range composed(t, open, "anchor").Resources {
|
||||||
|
if r["id"] == "mesh-controller.server" {
|
||||||
|
env, _ = r["env"].(map[string]any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env == nil {
|
||||||
|
t.Fatal("the control plane's container is not in its own node's declaration")
|
||||||
|
}
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
"MESH_STORE_INVENTORY_PORT": "6852",
|
||||||
|
"MESH_STORE_IDENTITY_PORT": "6852",
|
||||||
|
"MESH_STORE_LICENCES_PORT": "6852",
|
||||||
|
"MESH_BROKER_AMQP_PORT": "5679",
|
||||||
|
// Neither given nor assigned by the mesh: the manifest's own number is NOT the answer,
|
||||||
|
// because the sealed value beside it carries the port genesis wrote (finding F3).
|
||||||
|
"MESH_BROKER_ADDRESS_PORT": "",
|
||||||
|
"MESH_BROKER_MANAGEMENT_PORT": "",
|
||||||
|
} {
|
||||||
|
if env[key] != want {
|
||||||
|
t.Errorf("the control plane is told %s=%v; the node says %q", key, env[key], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSeatPorts is the control plane's manifest with the seat placeholders in its environment —
|
||||||
|
// added here until module.json carries them (the manifest lands one commit after the code that
|
||||||
|
// fills it, so a control plane one build behind never sees a placeholder it cannot fill).
|
||||||
|
func withSeatPorts(m catalogue.Manifest) catalogue.Manifest {
|
||||||
|
seatPorts := map[string]string{
|
||||||
|
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
|
||||||
|
"MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}",
|
||||||
|
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
|
||||||
|
"MESH_BROKER_AMQP_PORT": "${seat:mesh-broker:5672}",
|
||||||
|
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
|
||||||
|
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
||||||
|
}
|
||||||
|
out := m
|
||||||
|
out.Resources = nil
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] != "container" {
|
||||||
|
out.Resources = append(out.Resources, r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copied := map[string]any{}
|
||||||
|
for k, v := range r {
|
||||||
|
copied[k] = v
|
||||||
|
}
|
||||||
|
env := map[string]any{}
|
||||||
|
if had, ok := r["env"].(map[string]any); ok {
|
||||||
|
for k, v := range had {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range seatPorts {
|
||||||
|
if _, said := env[k]; !said {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copied["env"] = env
|
||||||
|
out.Resources = append(out.Resources, copied)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// aLoneNode is one capable machine with nothing placed on any network — the control-node during
|
||||||
|
// genesis, before the "network" step, which is after the store, the broker, the vault and the
|
||||||
|
// catalogue have each been built and pushed (finding F2).
|
||||||
|
func aLoneNode(t *testing.T) *stores {
|
||||||
|
t.Helper()
|
||||||
|
inventory.ForTest(t)
|
||||||
|
licences.ForTest(t)
|
||||||
|
open, err := openStores(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(open.Close)
|
||||||
|
for _, m := range provided {
|
||||||
|
if err := open.inventory.Provide(t.Context(), m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record, err := open.inventory.AddNode(t.Context(), "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reported, _ := json.Marshal(map[string]any{"capabilities": []map[string]any{
|
||||||
|
{"name": "container-runtime", "present": true}}})
|
||||||
|
var profile map[string]any
|
||||||
|
_ = json.Unmarshal(reported, &profile)
|
||||||
|
if err := open.inventory.RecordProfile(t.Context(), record.ID, profile); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := open.inventory.RecordSealingKey(t.Context(), record.ID, aPublicKey(t)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return open
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Before the network exists, the store's own node reaches it by loopback** — never refused,
|
||||||
|
// never handed the scheme: a genesis pushes the store, the broker, the vault and the catalogue to
|
||||||
|
// a node on no network, and builds the catalogue on a base it must be able to pull.
|
||||||
|
func TestOnANodeWithNoNetworkTheStoreIsReachedByLoopback(t *testing.T) {
|
||||||
|
open := aLoneNode(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, aStore())
|
||||||
|
register(t, open, catalogue.Manifest{Module: "builder", Version: "1",
|
||||||
|
Requires: []string{catalogue.ArtifactStoreProvision},
|
||||||
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-builder",
|
||||||
|
"image": "registry.example/mesh-builder@" + aDigest}}})
|
||||||
|
if err := open.inventory.RecordBuild(ctx, inventory.Build{
|
||||||
|
ID: "b1", Repository: "r", Module: "postgres", Commit: "abc",
|
||||||
|
Made: []inventory.Artifact{{Name: "runtime", Kind: "image",
|
||||||
|
Reference: catalogue.ArtifactStoreScheme + "postgres/runtime@" + aDigest}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
register(t, open, catalogue.Manifest{Module: "postgres", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "runtime", "type": "container", "name": "mesh-postgres",
|
||||||
|
"image": catalogue.ArtifactStoreScheme + "postgres/runtime@" + aDigest}}})
|
||||||
|
for _, module := range []string{"distribution", "builder", "postgres"} {
|
||||||
|
if _, err := assign(ctx, open, "anchor", module); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := open.inventory.SetSettings(ctx, "anchor", "distribution",
|
||||||
|
map[string]any{catalogue.PortsSetting: map[string]any{"5000": 5100}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var image any
|
||||||
|
for _, r := range composed(t, open, "anchor").Resources {
|
||||||
|
if r["id"] == "postgres.runtime" {
|
||||||
|
image = r["image"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if image != "127.0.0.1:5100/postgres/runtime@"+aDigest {
|
||||||
|
t.Fatalf("on the store's own node, off any network, the image is fetched as %v", image)
|
||||||
|
}
|
||||||
|
held := heldBy(ctx)
|
||||||
|
if got := held["postgres/runtime"]; got != "127.0.0.1:5100/postgres/runtime@"+aDigest {
|
||||||
|
t.Fatalf("a builder beside the store is handed the base %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,11 @@ func buildCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildFrom turns what a builder said into what the mesh keeps.
|
// buildFrom turns what a builder said into what the mesh keeps.
|
||||||
|
//
|
||||||
|
// **By digest and path, never by where it was pushed** (novox/hq 04-ISSUES/102). The builder
|
||||||
|
// says `<registry>:<port>/<module>/<artifact>@sha256:…`; the mesh records the artifact-store
|
||||||
|
// reference and composes the store's address back in where a reference is used. `against` is kept
|
||||||
|
// as announced: it is what the build stood on as the builder saw it, and the catalogue's edge.
|
||||||
func buildFrom(result link.BuildResult) inventory.Build {
|
func buildFrom(result link.BuildResult) inventory.Build {
|
||||||
kept := inventory.Build{
|
kept := inventory.Build{
|
||||||
ID: result.ID, Repository: result.Repository, Ref: result.Ref,
|
ID: result.ID, Repository: result.Repository, Ref: result.Ref,
|
||||||
@@ -77,16 +82,21 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
|||||||
// edges, and it is not always listening when a build happens — on a fresh mesh it cannot
|
// edges, and it is not always listening when a build happens — on a fresh mesh it cannot
|
||||||
// be, for exactly the modules it needs most. Keeping them is what makes a replay able to
|
// be, for exactly the modules it needs most. Keeping them is what makes a replay able to
|
||||||
// rebuild the graph rather than a list of names.
|
// rebuild the graph rather than a list of names.
|
||||||
Path: result.Path, Manifest: result.Manifest, Against: result.Against,
|
Path: result.Path, Against: result.Against,
|
||||||
}
|
}
|
||||||
|
var announced []inventory.Artifact
|
||||||
for _, made := range result.Made {
|
for _, made := range result.Made {
|
||||||
kept.Made = append(kept.Made, inventory.Artifact{
|
announced = append(announced, inventory.Artifact{
|
||||||
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
|
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
|
||||||
})
|
})
|
||||||
|
kept.Made = append(kept.Made, inventory.Artifact{
|
||||||
|
Name: made.Name, Kind: made.Kind, Reference: catalogue.Recorded(made.Reference),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
kept.Manifest = recordedManifest(result.Manifest, announced)
|
||||||
// The module name comes from the manifest, which only exists when the build got that far.
|
// The module name comes from the manifest, which only exists when the build got that far.
|
||||||
if len(result.Manifest) > 0 {
|
if len(kept.Manifest) > 0 {
|
||||||
if m, err := catalogue.ParseManifest(result.Manifest); err == nil {
|
if m, err := catalogue.ParseManifest(kept.Manifest); err == nil {
|
||||||
kept.Module = m.Module
|
kept.Module = m.Module
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,7 +388,8 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat
|
|||||||
}
|
}
|
||||||
defer open.Close()
|
defer open.Close()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
if err := inv.RecordBuild(ctx, buildFrom(result)); err != nil {
|
kept := buildFrom(result)
|
||||||
|
if err := inv.RecordBuild(ctx, kept); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,13 +399,15 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat
|
|||||||
return fmt.Errorf("%s could not build %s:\n%s", result.On, result.Repository, result.Failed)
|
return fmt.Errorf("%s could not build %s:\n%s", result.On, result.Repository, result.Failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, made := range result.Made {
|
// Said as recorded: what each artifact is, not where this builder happened to push it.
|
||||||
|
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
|
// 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.
|
// second thing to disagree about what a manifest is. The manifest as recorded, so the catalogue
|
||||||
manifest, err := catalogue.ParseManifest(result.Manifest)
|
// holds references by digest and path and every declaration composes the store's address in.
|
||||||
|
manifest, err := catalogue.ParseManifest(kept.Manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
return fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
||||||
result.On, result.Repository, err)
|
result.On, result.Repository, err)
|
||||||
@@ -481,6 +494,9 @@ type answers struct {
|
|||||||
// all, and one that does gets a refusal naming exactly what is missing — which is a better sentence
|
// all, and one that does gets a refusal naming exactly what is missing — which is a better sentence
|
||||||
// than a build command refusing to start because a query did not run. So the store not opening is
|
// than a build command refusing to start because a query did not run. So the store not opening is
|
||||||
// reported and the build goes ahead without it.
|
// reported and the build goes ahead without it.
|
||||||
|
//
|
||||||
|
// Routed through the artifact store as the network reaches it now (novox/hq 04-ISSUES/102): a
|
||||||
|
// base is recorded by digest and path, and a build machine needs something it can pull.
|
||||||
func heldBy(ctx context.Context) map[string]string {
|
func heldBy(ctx context.Context) map[string]string {
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -494,5 +510,18 @@ func heldBy(ctx context.Context) map[string]string {
|
|||||||
fmt.Fprintf(os.Stderr, "could not read what this mesh has built: %v\n", err)
|
fmt.Fprintf(os.Stderr, "could not read what this mesh has built: %v\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return held
|
address, err := whereABuilderReachesTheStore(ctx, open.inventory)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "could not find the artifact store on this mesh's network, so a "+
|
||||||
|
"module naming a base will be handed a reference nothing can fetch: %v\n", err)
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
if address == "" {
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
routed := make(map[string]string, len(held))
|
||||||
|
for repository, reference := range held {
|
||||||
|
routed[repository] = catalogue.Rerouted(reference, address)
|
||||||
|
}
|
||||||
|
return routed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,8 +440,11 @@ func namesInTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// artifactStoreOnNetwork is the machine and port the mesh's artifact store answers on, when a
|
// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the
|
||||||
// module providing it is assigned to a machine that is on the private network.
|
// port THAT MACHINE put it on — the node's setting when it was given one (novox/hq ADR 0100,
|
||||||
|
// 04-ISSUES/102), the mesh's assignment when it made one, and the manifest's own number only when
|
||||||
|
// neither says anything. Read exactly as a consumer's binding is, because the trust a machine
|
||||||
|
// writes for the store and the address it pulls from are the same fact as what a consumer is told.
|
||||||
//
|
//
|
||||||
// A lookup failure is an error, never "not found": collapsing the two composed a declaration
|
// A lookup failure is an error, never "not found": collapsing the two composed a declaration
|
||||||
// without the trust whenever the inventory hiccuped, delivered by a push that reported success —
|
// without the trust whenever the inventory hiccuped, delivered by a push that reported success —
|
||||||
@@ -454,29 +457,87 @@ func artifactStoreOnNetwork(ctx context.Context, inv *inventory.Inventory,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", false, fmt.Errorf("reading the catalogue: %w", err)
|
return "", "", false, fmt.Errorf("reading the catalogue: %w", err)
|
||||||
}
|
}
|
||||||
providers := map[string]string{} // module -> served port
|
providers := map[string]catalogue.Manifest{}
|
||||||
for name, m := range shelf {
|
for name, m := range shelf {
|
||||||
served, offers := m.Serves[catalogue.ArtifactStoreProvision]
|
if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers {
|
||||||
if !offers {
|
providers[name] = m
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p, ok := served["port"]; ok {
|
|
||||||
providers[name] = fmt.Sprintf("%v", p)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
return "", "", false, nil
|
return "", "", false, nil
|
||||||
}
|
}
|
||||||
|
// In a stated order, so two machines offering it would always answer the same one.
|
||||||
|
machines := make([]string, 0, len(on))
|
||||||
for machine := range on {
|
for machine := range on {
|
||||||
|
machines = append(machines, machine)
|
||||||
|
}
|
||||||
|
sort.Strings(machines)
|
||||||
|
for _, machine := range machines {
|
||||||
assigned, err := inv.Assigned(ctx, machine)
|
assigned, err := inv.Assigned(ctx, machine)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err)
|
return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err)
|
||||||
}
|
}
|
||||||
for _, a := range assigned {
|
for _, a := range assigned {
|
||||||
if p, ok := providers[a]; ok {
|
m, offers := providers[a]
|
||||||
return machine, p, true, nil
|
if !offers {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serves, err := servedOnNode(ctx, inv, machine, m, catalogue.ArtifactStoreProvision)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", "", false, nil
|
return "", "", false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// artifactStoreAddress is the artifact store as `forNode` reaches it: `<node>.internal:<port>`
|
||||||
|
// over the private network, or — when nothing is on the network yet — `127.0.0.1:<port>` for the
|
||||||
|
// node that holds the store itself, and "" for any other. The address composed into every
|
||||||
|
// reference the mesh built, at the moment it is used and never before (novox/hq 04-ISSUES/102).
|
||||||
|
//
|
||||||
|
// **Genesis places the network after the store, the broker, the vault and the catalogue.** Each
|
||||||
|
// of those is built and pushed to a node that is on no network, and the store is on that same
|
||||||
|
// node; an answer of "no store" there would refuse every one of those pushes and hand every one
|
||||||
|
// of those builds a base nothing can pull. Loopback is the truth on that machine, and it is the
|
||||||
|
// address genesis itself reaches the store by.
|
||||||
|
func artifactStoreAddress(ctx context.Context, inv *inventory.Inventory,
|
||||||
|
shelf map[string]catalogue.Manifest, forNode string) (string, error) {
|
||||||
|
onNetwork, err := whereEveryoneIs(ctx, inv, shelf)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
on := map[string]bool{}
|
||||||
|
for name := range onNetwork {
|
||||||
|
on[name] = true
|
||||||
|
}
|
||||||
|
node, port, found, err := artifactStoreOnNetwork(ctx, inv, on)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
return overlay.InternalName(node) + ":" + port, nil
|
||||||
|
}
|
||||||
|
holder, port, found, err := artifactStoreHolder(ctx, inv)
|
||||||
|
if err != nil || !found || holder != forNode {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "127.0.0.1:" + port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// artifactStoreHolder is whichever node is assigned a module offering the artifact store, on or
|
||||||
|
// off the network, and the port that node put it on.
|
||||||
|
func artifactStoreHolder(ctx context.Context, inv *inventory.Inventory) (node, port string, found bool, err error) {
|
||||||
|
nodes, err := inv.Nodes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false, err
|
||||||
|
}
|
||||||
|
all := map[string]bool{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
all[n.Name] = true
|
||||||
|
}
|
||||||
|
return artifactStoreOnNetwork(ctx, inv, all)
|
||||||
|
}
|
||||||
|
|||||||
+138
-20
@@ -229,28 +229,10 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
// What that module says a consumer needs to know, with that node's settings on
|
// What that module says a consumer needs to know, with that node's settings on
|
||||||
// it: a port somebody moved on the provider is a port its consumers must be told
|
// it: a port somebody moved on the provider is a port its consumers must be told
|
||||||
// about, and the two coming from different places is how they come to disagree.
|
// about, and the two coming from different places is how they come to disagree.
|
||||||
assigned, err := portsOn(ctx, inv, o.node.Name, m.Module)
|
serves, err := servedOnNode(ctx, inv, o.node.Name, m, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.World{}, err
|
return catalogue.World{}, err
|
||||||
}
|
}
|
||||||
layers, err := inv.SettingsFor(ctx, o.node.Name, m.Module)
|
|
||||||
if err != nil {
|
|
||||||
return catalogue.World{}, err
|
|
||||||
}
|
|
||||||
// A port that node was given is where its consumers reach it (novox/hq ADR
|
|
||||||
// 0100). Unreadable given ports are that node's refusal to report, not this one's.
|
|
||||||
if given, err := catalogue.GivenPorts(m, layers); err == nil {
|
|
||||||
for wanted, at := range given {
|
|
||||||
assigned[wanted] = at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serves := catalogue.ServedOn(m, name, assigned)
|
|
||||||
if len(serves) > 0 {
|
|
||||||
serves, err = catalogue.Settle(serves, layers)
|
|
||||||
if err != nil {
|
|
||||||
return catalogue.World{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
offered[name] = append(offered[name], catalogue.Provider{
|
offered[name] = append(offered[name], catalogue.Provider{
|
||||||
Node: o.node.Name, At: o.node.At, Serves: serves})
|
Node: o.node.Name, At: o.node.At, Serves: serves})
|
||||||
}
|
}
|
||||||
@@ -499,6 +481,22 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The artifact store as this node reaches it now — the address every image and archive the
|
||||||
|
// mesh built is fetched through, composed here and recorded nowhere — with what the mesh has
|
||||||
|
// built, so a reference recorded with an address before that is re-routed too.
|
||||||
|
artifactStore, err := artifactStoreAddress(ctx, inv, shelf, node)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
|
held, err := inv.Held(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
|
built := make(map[string]bool, len(held))
|
||||||
|
for repository := range held {
|
||||||
|
built[repository] = true
|
||||||
|
}
|
||||||
|
|
||||||
// And every machine's name, so a container can reach one. The same set that writes the
|
// And every machine's name, so a container can reach one. The same set that writes the
|
||||||
// machine's own hosts file — one reading, so a container and its machine cannot disagree
|
// machine's own hosts file — one reading, so a container and its machine cannot disagree
|
||||||
// about where another machine is.
|
// about where another machine is.
|
||||||
@@ -566,11 +564,18 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
taken[m] = true
|
taken[m] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Where this node put the foundation's servers, for the control plane's own connections
|
||||||
|
// (novox/hq 04-ISSUES/102): read from the node's settings for whatever claims each seat,
|
||||||
|
// exactly as a consumer's binding is, never from what genesis wrote into a secret.
|
||||||
|
seats, err := seatsOn(ctx, inv, shelf, node, plan.Modules, record.Adopted, taken)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
|
}
|
||||||
return catalogue.Rendering{
|
return catalogue.Rendering{
|
||||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
||||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||||
Suffix: overlay.Suffix(), Foundation: foundation, Kept: kept, Adopted: record.Adopted,
|
Suffix: overlay.Suffix(), Foundation: foundation, Kept: kept, Adopted: record.Adopted,
|
||||||
Given: given, Taken: taken,
|
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built,
|
||||||
}, record, nil
|
}, record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,6 +972,119 @@ func managerPublicKeyFor(
|
|||||||
return inv.SealingKeyOf(ctx, node)
|
return inv.SealingKeyOf(ctx, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// servedOnNode is what a module on a node tells a consumer of one of its provisions, with THAT
|
||||||
|
// node's ports on it: the port the node was given (novox/hq ADR 0100) over the one the mesh
|
||||||
|
// assigned over the manifest's own, settled with the node's settings layers.
|
||||||
|
//
|
||||||
|
// **The one derivation** for every reader of a provider's address — a consumer's binding, the
|
||||||
|
// artifact store's trust and the references composed through it (04-ISSUES/102). Unreadable
|
||||||
|
// given ports are that node's refusal to report, not this reader's.
|
||||||
|
func servedOnNode(ctx context.Context, inv *inventory.Inventory, node string,
|
||||||
|
m catalogue.Manifest, provision string) (map[string]any, error) {
|
||||||
|
ports, layers, err := portsGivenOn(ctx, inv, node, m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
serves := catalogue.ServedOn(m, provision, ports)
|
||||||
|
if len(serves) > 0 {
|
||||||
|
serves, err = catalogue.Settle(serves, layers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return serves, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the
|
||||||
|
// node's settings layers for the module, read once for both.
|
||||||
|
func portsGivenOn(ctx context.Context, inv *inventory.Inventory, node string,
|
||||||
|
m catalogue.Manifest) (map[int]int, []catalogue.Layer, error) {
|
||||||
|
ports, err := portsOn(ctx, inv, node, m.Module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
layers, err := inv.SettingsFor(ctx, node, m.Module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if given, err := catalogue.GivenPorts(m, layers); err == nil {
|
||||||
|
for wanted, at := range given {
|
||||||
|
ports[wanted] = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports, layers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatsOn is where a node put the holder of each mesh-scoped seat, by seat and by the port the
|
||||||
|
// holder's software uses — what ${seat:…} answers with (novox/hq 04-ISSUES/102).
|
||||||
|
//
|
||||||
|
// Read for every module in the catalogue that claims a seat, in this node's set or not: the store
|
||||||
|
// and the broker are given their ports at genesis, as settings on a module that may be registered
|
||||||
|
// and not yet assigned (04-ISSUES/085), and the control plane must follow that setting from the
|
||||||
|
// first declaration it composes for itself. A holder in this node's set wins over one that is not.
|
||||||
|
func seatsOn(ctx context.Context, inv *inventory.Inventory, shelf map[string]catalogue.Manifest,
|
||||||
|
node string, inSet []catalogue.Manifest, adopted bool, taken map[string]bool) (map[string]map[int]int, error) {
|
||||||
|
assigned := map[string]bool{}
|
||||||
|
for _, m := range inSet {
|
||||||
|
assigned[m.Module] = true
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(shelf))
|
||||||
|
for name := range shelf {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
seats := map[string]map[int]int{}
|
||||||
|
for _, name := range names {
|
||||||
|
m := shelf[name]
|
||||||
|
var claims []string
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if c.At() == catalogue.ScopeMesh {
|
||||||
|
claims = append(claims, c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(claims) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// **Only a port the node was given or the mesh assigned — never the manifest's own
|
||||||
|
// number.** The sealed value the answer sits beside carries the port genesis wrote, which
|
||||||
|
// on a given-port node is the predecessor's; a manifest's long-form mapping is the
|
||||||
|
// catalogue's default, and answering with it would override the right number with one
|
||||||
|
// the mesh never checked (the contract in seat_into.go). And on an adopted node a holder
|
||||||
|
// assigned but not yet taken is the found container, on the ports it was found with, not
|
||||||
|
// the declaration's — so its mesh-assigned ports do not count there either; a given port
|
||||||
|
// does, because a given port is the found one by construction (ADR 0100).
|
||||||
|
ports, _, err := portsGivenOn(ctx, inv, node, m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if adopted && !taken[name] {
|
||||||
|
layers, err := inv.SettingsFor(ctx, node, m.Module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ports = map[int]int{}
|
||||||
|
if given, err := catalogue.GivenPorts(m, layers); err == nil {
|
||||||
|
ports = given
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ports) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, seat := range claims {
|
||||||
|
if seats[seat] == nil {
|
||||||
|
seats[seat] = map[int]int{}
|
||||||
|
}
|
||||||
|
for wanted, at := range ports {
|
||||||
|
if _, said := seats[seat][wanted]; said && !assigned[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seats[seat][wanted] = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seats, nil
|
||||||
|
}
|
||||||
|
|
||||||
// portsOn is one module's assignments on one machine, by the port the software uses.
|
// portsOn is one module's assignments on one machine, by the port the software uses.
|
||||||
func portsOn(
|
func portsOn(
|
||||||
ctx context.Context, inv *inventory.Inventory, node, module string,
|
ctx context.Context, inv *inventory.Inventory, node, module string,
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a build is recorded as, and what it is announced and handed on as.
|
||||||
|
//
|
||||||
|
// **Recorded by what it is; routed where it is used** (novox/hq 04-ISSUES/102). The builder
|
||||||
|
// announces each artifact by the reference it pushed — `<registry>:<port>/<module>/<artifact>@sha256:…`
|
||||||
|
// — and the manifest with those references in it. The mesh records the digest and the path
|
||||||
|
// (catalogue.Recorded) and composes the store's address back in wherever a machine or a builder
|
||||||
|
// needs a reference it can fetch: a declaration, a replay to the catalogue, the bases a build is
|
||||||
|
// given. Nothing recorded carries a port, so moving the store is a settings change and not a
|
||||||
|
// rebuild of everything the mesh has ever built.
|
||||||
|
|
||||||
|
// recordedManifest is a build's manifest with the store's address taken off every reference the
|
||||||
|
// build itself made. Other references — an image a module runs from a public registry — are what
|
||||||
|
// they were, which is why this rewrites only what `made` names rather than everything that looks
|
||||||
|
// like an address.
|
||||||
|
func recordedManifest(raw json.RawMessage, made []inventory.Artifact) json.RawMessage {
|
||||||
|
announced := map[string]bool{}
|
||||||
|
for _, a := range made {
|
||||||
|
announced[a.Reference] = true
|
||||||
|
}
|
||||||
|
return withReferences(raw, func(ref string) (string, bool) {
|
||||||
|
if !announced[ref] {
|
||||||
|
return ref, false
|
||||||
|
}
|
||||||
|
return catalogue.Recorded(ref), true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// routedManifest is a recorded manifest with the store's address, as this network reaches it now,
|
||||||
|
// composed into every reference the build made — recorded either way, before or after references
|
||||||
|
// were kept without their address.
|
||||||
|
func routedManifest(raw json.RawMessage, made []inventory.Artifact, address string) json.RawMessage {
|
||||||
|
recorded := map[string]bool{}
|
||||||
|
for _, a := range made {
|
||||||
|
recorded[catalogue.Recorded(a.Reference)] = true
|
||||||
|
}
|
||||||
|
return withReferences(raw, func(ref string) (string, bool) {
|
||||||
|
if !recorded[catalogue.Recorded(ref)] {
|
||||||
|
return ref, false
|
||||||
|
}
|
||||||
|
return catalogue.Rerouted(ref, address), true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// withReferences applies `rewrite` to each resource's `image` and `source`, and hands the manifest
|
||||||
|
// back untouched — byte for byte — when nothing changed or it could not be read: what a manifest
|
||||||
|
// is, is the parser's to say, and it says so with a better sentence than anything here would.
|
||||||
|
func withReferences(raw json.RawMessage, rewrite func(string) (string, bool)) json.RawMessage {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
var manifest map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
resources, _ := manifest["resources"].([]any)
|
||||||
|
changed := false
|
||||||
|
for _, r := range resources {
|
||||||
|
resource, ok := r.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, key := range []string{"image", "source"} {
|
||||||
|
if written, ok := resource[key].(string); ok {
|
||||||
|
if rewritten, did := rewrite(written); did && rewritten != written {
|
||||||
|
resource[key] = rewritten
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(manifest)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// routedArtifacts is a build's artifacts as something can fetch them now.
|
||||||
|
func routedArtifacts(made []inventory.Artifact, address string) []inventory.Artifact {
|
||||||
|
if address == "" {
|
||||||
|
return made
|
||||||
|
}
|
||||||
|
out := make([]inventory.Artifact, 0, len(made))
|
||||||
|
for _, a := range made {
|
||||||
|
out = append(out, inventory.Artifact{Name: a.Name, Kind: a.Kind,
|
||||||
|
Reference: catalogue.Rerouted(a.Reference, address)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// whereTheStoreIs is the artifact store's address as something on `forNode` reaches it, or "" —
|
||||||
|
// read for a caller that has the inventory open and nothing else in hand. With no node named, the
|
||||||
|
// store's own node: loopback when nothing is on the network yet.
|
||||||
|
func whereTheStoreIs(ctx context.Context, inv *inventory.Inventory, forNode string) (string, error) {
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if forNode == "" {
|
||||||
|
if holder, _, found, err := artifactStoreHolder(ctx, inv); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if found {
|
||||||
|
forNode = holder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artifactStoreAddress(ctx, inv, shelf, forNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// whereABuilderReachesTheStore is the store's address for the machine that builds: the network's
|
||||||
|
// when there is one, else loopback on the store's own node — when that node also holds a module
|
||||||
|
// requiring the store, which is what a builder is (genesis: one node holds both).
|
||||||
|
func whereABuilderReachesTheStore(ctx context.Context, inv *inventory.Inventory) (string, error) {
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
holder, _, found, err := artifactStoreHolder(ctx, inv)
|
||||||
|
if err != nil || !found {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
assigned, err := inv.Assigned(ctx, holder)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
besideIt := false
|
||||||
|
for _, a := range assigned {
|
||||||
|
for _, r := range shelf[a].Requires {
|
||||||
|
if r == catalogue.ArtifactStoreProvision {
|
||||||
|
besideIt = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !besideIt {
|
||||||
|
holder = ""
|
||||||
|
}
|
||||||
|
return artifactStoreAddress(ctx, inv, shelf, holder)
|
||||||
|
}
|
||||||
@@ -173,11 +173,21 @@ func sayUpgrade(module string, u inventory.Upgrade) string {
|
|||||||
// catalogue misses nothing — but the modules built before it first ran were announced to a queue
|
// catalogue misses nothing — but the modules built before it first ran were announced to a queue
|
||||||
// that did not exist, and on a fresh mesh those are always the same three: the shared base, the
|
// that did not exist, and on a fresh mesh those are always the same three: the shared base, the
|
||||||
// store the catalogue runs on, and the catalogue itself.
|
// store the catalogue runs on, and the catalogue itself.
|
||||||
|
//
|
||||||
|
// **Announced as fetchable, recorded as what it is** (novox/hq 04-ISSUES/102). A build is
|
||||||
|
// recorded by digest and path; the catalogue hears the builder's own announcements, which name
|
||||||
|
// the store's address, so a replay composes the address back in — the store's address as the
|
||||||
|
// network reaches it NOW, which is the whole point of not having recorded the old one. With no
|
||||||
|
// store on the network yet, the recorded form goes as it is.
|
||||||
func (f following) Announceable(ctx context.Context) ([]link.Announcement, error) {
|
func (f following) Announceable(ctx context.Context) ([]link.Announcement, error) {
|
||||||
builds, err := f.open.inventory.Announceable(ctx)
|
builds, err := f.open.inventory.Announceable(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
address, err := whereTheStoreIs(ctx, f.open.inventory, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
out := make([]link.Announcement, 0, len(builds))
|
out := make([]link.Announcement, 0, len(builds))
|
||||||
for _, b := range builds {
|
for _, b := range builds {
|
||||||
a := link.Announcement{
|
a := link.Announcement{
|
||||||
@@ -186,8 +196,11 @@ func (f following) Announceable(ctx context.Context) ([]link.Announcement, error
|
|||||||
}
|
}
|
||||||
if len(b.Manifest) > 0 {
|
if len(b.Manifest) > 0 {
|
||||||
a.Manifest = b.Manifest
|
a.Manifest = b.Manifest
|
||||||
|
if address != "" {
|
||||||
|
a.Manifest = routedManifest(b.Manifest, b.Made, address)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, made := range b.Made {
|
for _, made := range routedArtifacts(b.Made, address) {
|
||||||
a.Made = append(a.Made, link.MadeArtifact{
|
a.Made = append(a.Made, link.MadeArtifact{
|
||||||
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
|
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ type Broker struct {
|
|||||||
var ErrNotConfigured = errors.New("this control plane has not been told about its broker")
|
var ErrNotConfigured = errors.New("this control plane has not been told about its broker")
|
||||||
|
|
||||||
// FromEnvironment reads the two settings, if they are there.
|
// FromEnvironment reads the two settings, if they are there.
|
||||||
|
//
|
||||||
|
// The address's port follows MESH_BROKER_ADDRESS_PORT when the node's settings moved the bus
|
||||||
|
// (novox/hq 04-ISSUES/102): the address genesis wrote is a public name and the port genesis
|
||||||
|
// chose, and only the port is the node's to move.
|
||||||
func FromEnvironment() (Broker, error) {
|
func FromEnvironment() (Broker, error) {
|
||||||
address, err := envfile.Value(AddressVar)
|
address, err := envfile.Placed(AddressVar)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Broker{}, err
|
return Broker{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,3 +178,32 @@ func TestBothTogetherGiveABroker(t *testing.T) {
|
|||||||
t.Errorf("got %+v", known)
|
t.Errorf("got %+v", known)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The node moved the bus, and the address a token carries follows (novox/hq 04-ISSUES/102).
|
||||||
|
func TestTheAddressPortFollowsThePortTwin(t *testing.T) {
|
||||||
|
t.Setenv(AddressVar, "broker.example:5671")
|
||||||
|
t.Setenv(AddressVar+"_FILE", "")
|
||||||
|
path, _ := writeCertificate(t)
|
||||||
|
t.Setenv(CertificateVar, path)
|
||||||
|
t.Setenv(AddressVar+"_PORT", "5679")
|
||||||
|
b, err := FromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b.Address != "broker.example:5679" {
|
||||||
|
t.Fatalf("the address is %q; the node put the bus on 5679", b.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheManagementPortFollowsThePortTwin(t *testing.T) {
|
||||||
|
t.Setenv(ManagementVar, "http://guest:guest@127.0.0.1:15672")
|
||||||
|
t.Setenv(ManagementVar+"_FILE", "")
|
||||||
|
t.Setenv(ManagementVar+"_PORT", "15673")
|
||||||
|
m, err := ManagementFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.base.Host != "127.0.0.1:15673" {
|
||||||
|
t.Fatalf("the management API is at %q; the node put it on 15673", m.base.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,8 +41,11 @@ type Management struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ManagementFromEnvironment reads where the management API is, if it is configured.
|
// ManagementFromEnvironment reads where the management API is, if it is configured.
|
||||||
|
//
|
||||||
|
// On the port MESH_BROKER_MANAGEMENT_PORT names when the node moved it (novox/hq 04-ISSUES/102);
|
||||||
|
// the URL's own port otherwise.
|
||||||
func ManagementFromEnvironment() (*Management, error) {
|
func ManagementFromEnvironment() (*Management, error) {
|
||||||
raw, err := envfile.Value(ManagementVar)
|
raw, err := envfile.Placed(ManagementVar)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the mesh built, named by what it is rather than by where it was pushed.
|
||||||
|
//
|
||||||
|
// **An image is recorded by its digest and its path; the registry's address is a route to it**
|
||||||
|
// (novox/hq 04-ISSUES/102). A build used to be recorded as `<registry>:<port>/<module>/<artifact>@sha256:…`
|
||||||
|
// — the reference the builder pushed to, kept whole — and every declaration carried that literal.
|
||||||
|
// Move the registry's port, or the registry, and every fresh pull of a mesh image fails: a new
|
||||||
|
// node, a recreate after eviction. The digest is the identity; the address is the node's setting
|
||||||
|
// for the module that serves the artifact store, and it is read from there when a reference is
|
||||||
|
// composed, never written into a record.
|
||||||
|
//
|
||||||
|
// So a kept reference has a scheme of the mesh's own, named after the provision that answers it:
|
||||||
|
//
|
||||||
|
// artifact-store://<module>/<artifact>@sha256:<hex> an image
|
||||||
|
// artifact-store://<module>/<artifact>/blobs/sha256:<hex> an archive
|
||||||
|
//
|
||||||
|
// No runtime knows the scheme. That is the point of it being one: a reference that leaks to a
|
||||||
|
// machine uncomposed is refused by the runtime as malformed, in front of whoever sent it, rather
|
||||||
|
// than pulled from a public registry that happens to have a repository by that name — which is
|
||||||
|
// what an address-less `<module>/<artifact>@sha256:…` would be.
|
||||||
|
|
||||||
|
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
|
||||||
|
// the store's address.
|
||||||
|
const ArtifactStoreScheme = ArtifactStoreProvision + "://"
|
||||||
|
|
||||||
|
// Recorded is a reference as the mesh records it: the artifact store's address, if the builder wrote
|
||||||
|
// one, taken off.
|
||||||
|
//
|
||||||
|
// For a reference the builder announced — one it pushed to the store — which is the only kind
|
||||||
|
// this is called on. An image the builder named `<host>/<path>@sha256:…` is kept as its path; an
|
||||||
|
// archive it named `http://<host>/v2/<path>/blobs/<digest>` likewise. A reference with no address
|
||||||
|
// in it — an image id from a genesis build that had nowhere to publish, a package version — is
|
||||||
|
// what it was.
|
||||||
|
func Recorded(reference string) string {
|
||||||
|
if strings.HasPrefix(reference, ArtifactStoreScheme) {
|
||||||
|
return reference
|
||||||
|
}
|
||||||
|
if rest, isURL := strings.CutPrefix(reference, "http://"); isURL {
|
||||||
|
if _, path, ok := strings.Cut(rest, "/v2/"); ok && strings.Contains(path, "/blobs/") {
|
||||||
|
return ArtifactStoreScheme + path
|
||||||
|
}
|
||||||
|
return reference
|
||||||
|
}
|
||||||
|
host, path, ok := strings.Cut(reference, "/")
|
||||||
|
if !ok || !isRegistryHost(host) || !strings.Contains(path, "@sha256:") {
|
||||||
|
return reference
|
||||||
|
}
|
||||||
|
return ArtifactStoreScheme + path
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRegistryHost is the runtime's own rule for reading the first component of a reference as a
|
||||||
|
// registry rather than as a namespace: it has a dot or a port in it, or it is localhost.
|
||||||
|
func isRegistryHost(component string) bool {
|
||||||
|
return component == "localhost" || strings.ContainsAny(component, ".:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// InArtifactStore reports whether a reference is a kept one, and what it names there.
|
||||||
|
func InArtifactStore(reference string) (path string, kept bool) {
|
||||||
|
return strings.CutPrefix(reference, ArtifactStoreScheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routed is a kept reference as a machine fetches it, through the artifact store at `address`
|
||||||
|
// (host:port). A reference that is not a kept one is what it was.
|
||||||
|
func Routed(reference, address string) string {
|
||||||
|
path, kept := InArtifactStore(reference)
|
||||||
|
if !kept {
|
||||||
|
return reference
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "/blobs/") {
|
||||||
|
return "http://" + address + "/v2/" + path
|
||||||
|
}
|
||||||
|
return address + "/" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rerouted is a reference the mesh recorded, whichever way it was recorded, as a machine fetches
|
||||||
|
// it now: a kept one composed with the store's address, and one recorded before references were
|
||||||
|
// kept without their address — the builder's own `<host>/<path>@sha256:…` — re-routed to where
|
||||||
|
// the store is now. Only for references that are the mesh's own: everything a build record
|
||||||
|
// holds is, by construction.
|
||||||
|
func Rerouted(reference, address string) string {
|
||||||
|
return Routed(Recorded(reference), address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
|
||||||
|
//
|
||||||
|
// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is
|
||||||
|
// routed through the store as this network reaches it now. A reference recorded with an address
|
||||||
|
// before references were kept without one is re-routed the same way — but only when the mesh
|
||||||
|
// built it (`with.Built` names every `<module>/<artifact>` it has), because a module may run an
|
||||||
|
// image from a public registry under its own name and that one is exactly where it says.
|
||||||
|
//
|
||||||
|
// A kept reference with no store to route it through is refused: sent as it is, the runtime would
|
||||||
|
// refuse the scheme on the machine, one push away from the reason.
|
||||||
|
//
|
||||||
|
// **What this does not reach: the images genesis pinned.** The installer builds the control plane
|
||||||
|
// and the builder before the mesh exists, pushes them itself and pins their manifests to
|
||||||
|
// `<registry>:<port>/mesh-controller@…` and `<registry>:<port>/mesh-builder@…` — single-segment
|
||||||
|
// repositories with no build record, so `with.Built` does not name them and they are left as
|
||||||
|
// written until each is rebuilt through the mesh, which records it by digest and path. Until then
|
||||||
|
// a registry that moves strands exactly those two on a recreate, and the control plane's is the
|
||||||
|
// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the
|
||||||
|
// store (novox/hq 04-ISSUES/102, finding F4).
|
||||||
|
func artifactsInto(resource map[string]any, module string, with Rendering) error {
|
||||||
|
for _, key := range []string{"image", "source"} {
|
||||||
|
written, ok := resource[key].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, kept := InArtifactStore(written); kept {
|
||||||
|
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, ArtifactStoreProvision)
|
||||||
|
}
|
||||||
|
resource[key] = Routed(written, with.ArtifactStore)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if with.ArtifactStore == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recorded := Recorded(written)
|
||||||
|
if recorded == written {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path, _ := InArtifactStore(recorded)
|
||||||
|
repository := path
|
||||||
|
if at := strings.IndexAny(path, "@"); at >= 0 {
|
||||||
|
repository = path[:at]
|
||||||
|
} else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 {
|
||||||
|
repository = path[:blobs]
|
||||||
|
}
|
||||||
|
if with.Built[repository] {
|
||||||
|
resource[key] = Routed(recorded, with.ArtifactStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A build is recorded by what it is; where it is pushed is composed where it is used (novox/hq
|
||||||
|
// 04-ISSUES/102).
|
||||||
|
|
||||||
|
var digest = "sha256:" + strings.Repeat("d", 64)
|
||||||
|
|
||||||
|
func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"anchor.internal:5100/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
|
||||||
|
"localhost:5000/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
|
||||||
|
"http://anchor.internal:5100/v2/gitea/config/blobs/" + digest: ArtifactStoreScheme + "gitea/config/blobs/" + digest,
|
||||||
|
ArtifactStoreScheme + "gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
|
||||||
|
digest: digest,
|
||||||
|
"@novox/sdk@1.2.3": "@novox/sdk@1.2.3",
|
||||||
|
"gitea/gitea@" + digest: "gitea/gitea@" + digest,
|
||||||
|
"https://registry.example/v2/gitea/config/blobs/" + digest: "https://registry.example/v2/gitea/config/blobs/" + digest,
|
||||||
|
}
|
||||||
|
for announced, want := range cases {
|
||||||
|
if got := Recorded(announced); got != want {
|
||||||
|
t.Errorf("Recorded(%q) = %q, want %q", announced, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARecordedReferenceIsRoutedThroughTheStoreAsItIsNow(t *testing.T) {
|
||||||
|
if got := Routed(ArtifactStoreScheme+"gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest {
|
||||||
|
t.Errorf("an image is fetched as %q", got)
|
||||||
|
}
|
||||||
|
if got := Routed(ArtifactStoreScheme+"gitea/config/blobs/"+digest, "anchor.internal:5101"); got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
|
||||||
|
t.Errorf("an archive is fetched as %q", got)
|
||||||
|
}
|
||||||
|
if got := Routed("gitea/gitea@"+digest, "anchor.internal:5101"); got != "gitea/gitea@"+digest {
|
||||||
|
t.Errorf("a reference that is not the store's was routed: %q", got)
|
||||||
|
}
|
||||||
|
// One recorded before references were kept without their address follows the store too.
|
||||||
|
if got := Rerouted("anchor.internal:5100/gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest {
|
||||||
|
t.Errorf("a reference recorded with the old address stays there: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The address is composed into a declaration, and the record never carries it.**
|
||||||
|
func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
|
||||||
|
m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
|
||||||
|
{"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"},
|
||||||
|
{"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"},
|
||||||
|
{"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest},
|
||||||
|
}, Build: &Build{Artifacts: []Artifact{
|
||||||
|
{Name: "server", Kind: ArtifactImage, From: "Dockerfile"},
|
||||||
|
{Name: "config", Kind: ArtifactArchive, From: "config"},
|
||||||
|
}}}
|
||||||
|
resolved, err := m.Resolve([]Built{
|
||||||
|
{Name: "server", Kind: ArtifactImage, Reference: ArtifactStoreScheme + "gitea/server@" + digest},
|
||||||
|
{Name: "config", Kind: ArtifactArchive, Reference: ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{resolved}}
|
||||||
|
|
||||||
|
out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest {
|
||||||
|
t.Errorf("the image the mesh built is fetched as %v", got)
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
|
||||||
|
t.Errorf("the archive the mesh built is fetched from %v", got)
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest {
|
||||||
|
t.Errorf("an image from a public registry was routed through the store: %v", got)
|
||||||
|
}
|
||||||
|
// The manifest the mesh holds still says what it is, not where it was fetched from.
|
||||||
|
if got := resolved.Resources[0]["image"]; got != ArtifactStoreScheme+"gitea/server@"+digest {
|
||||||
|
t.Errorf("composing wrote the address into the catalogue's copy: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the store moves: the same record, another address, without a rebuild.
|
||||||
|
out, err = r.Declaration(Rendering{ArtifactStore: "laptop.internal:5000"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest {
|
||||||
|
t.Errorf("after the store moved, the image is still fetched as %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) {
|
||||||
|
m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
|
||||||
|
{"id": "server", "type": "container", "name": "mesh-gitea",
|
||||||
|
"image": ArtifactStoreScheme + "gitea/server@" + digest},
|
||||||
|
}}
|
||||||
|
_, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(Rendering{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no artifact store") {
|
||||||
|
t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A reference recorded with an address before this follows the store too** — when the mesh
|
||||||
|
// built it. A module running an image straight from a public registry under its own name is left
|
||||||
|
// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called.
|
||||||
|
func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) {
|
||||||
|
m := Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{
|
||||||
|
{"id": "server", "type": "container", "name": "mesh-keycloak",
|
||||||
|
"image": "anchor.internal:5100/keycloak/server@" + digest},
|
||||||
|
{"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream",
|
||||||
|
"image": "quay.io/keycloak/keycloak@" + digest},
|
||||||
|
}}
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{m}}
|
||||||
|
out, err := r.Declaration(Rendering{
|
||||||
|
ArtifactStore: "anchor.internal:5101",
|
||||||
|
Built: map[string]bool{"keycloak/server": true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest {
|
||||||
|
t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got)
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest {
|
||||||
|
t.Errorf("a public image was re-routed through the store: %v", got)
|
||||||
|
}
|
||||||
|
// Nothing known to be built: nothing re-routed, nothing refused.
|
||||||
|
out, err = r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest {
|
||||||
|
t.Errorf("with no build record, a reference was rewritten: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,23 @@ type Rendering struct {
|
|||||||
// from these only (ADR 0103): a port of a module assigned but not taken may still be the
|
// from these only (ADR 0103): a port of a module assigned but not taken may still be the
|
||||||
// predecessor's.
|
// predecessor's.
|
||||||
Taken map[string]bool
|
Taken map[string]bool
|
||||||
|
|
||||||
|
// Seats is where this machine put each mesh-scoped seat's holder, by seat and by the port the
|
||||||
|
// holder's software uses (novox/hq 04-ISSUES/102) — read from the node's settings and
|
||||||
|
// assignments for whichever module claims the seat, whether or not it is in this node's set.
|
||||||
|
// What ${seat:…} answers with; see seat_into.go for why the answer may be absent.
|
||||||
|
Seats map[string]map[int]int
|
||||||
|
|
||||||
|
// ArtifactStore is the mesh's artifact store as this network reaches it (host:port) — the
|
||||||
|
// node holding it and the port that node put it on — or empty when the mesh has none on its
|
||||||
|
// network yet. Composed into every image and archive the mesh built, at this moment and never
|
||||||
|
// stored (novox/hq 04-ISSUES/102).
|
||||||
|
ArtifactStore string
|
||||||
|
|
||||||
|
// Built is every `<module>/<artifact>` 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.
|
||||||
|
Built map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// machinePort is where a module's port lives on this machine, or the port itself when the mesh has
|
// machinePort is where a module's port lives on this machine, or the port itself when the mesh has
|
||||||
@@ -539,6 +556,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
if err := portInto(copied, m.Module, m.Listens, with); err != nil {
|
if err := portInto(copied, m.Module, m.Listens, with); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// And where this machine put the foundation's servers, for the one module that
|
||||||
|
// reaches them by seat rather than by binding (novox/hq 04-ISSUES/102).
|
||||||
|
if err := seatInto(copied, m.Module, with); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := machineInto(copied, thisMachine, m.Module); err != nil {
|
if err := machineInto(copied, thisMachine, m.Module); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -550,6 +572,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
if err := built(copied, m.Module); err != nil {
|
if err := built(copied, m.Module); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// What the mesh built is kept by digest and path; the store's address is this
|
||||||
|
// network's now, composed here and never recorded (novox/hq 04-ISSUES/102).
|
||||||
|
if err := artifactsInto(copied, m.Module, with); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
publishedOn(copied, m.Module, with)
|
publishedOn(copied, m.Module, with)
|
||||||
copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
|
copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
|
||||||
// A service saying what it reflects names resources within its own module, so those
|
// A service saying what it reflects names resources within its own module, so those
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Telling a module where this machine put the holder of a seat.
|
||||||
|
//
|
||||||
|
// **The foundation's ports are the node's** (novox/hq ADR 0100): the port a foundation server was
|
||||||
|
// given at genesis becomes that node's setting for the module that serves it, and every reader
|
||||||
|
// follows the setting. Every consumer's binding did. The control plane's own connections did not
|
||||||
|
// (04-ISSUES/102): they are written at genesis, before any module exists to bind to — full
|
||||||
|
// connection strings, sealed, with the port inside — so when the node moved the store, the
|
||||||
|
// control plane went on dialling where genesis had written and the mesh was headless.
|
||||||
|
//
|
||||||
|
// The control plane cannot open its own sealed connection to move the port, and it cannot bind
|
||||||
|
// the store as a consumer would: a binding mints a credential, and what the control plane holds
|
||||||
|
// is the foundation's superuser, made before the mesh. What it can do is read the node's settings
|
||||||
|
// when it composes its own declaration — it is the thing that composes every other module's — and
|
||||||
|
// say in its own environment which port this machine put the store at.
|
||||||
|
//
|
||||||
|
// So a module may ask about a **seat** (ADR 0079: a foundation seat is named after the server it
|
||||||
|
// guards — `mesh-store`, `mesh-broker`). `${seat:mesh-store:5432}` is "the port this machine put
|
||||||
|
// the holder of the mesh-store seat's 5432 at". Not a provision: nothing is required, nothing is
|
||||||
|
// granted, no credential is minted. A seat is the mesh's own vocabulary for the store and the
|
||||||
|
// broker, which is what makes this the control plane's way of naming them and not a way for a
|
||||||
|
// module to reach a server it was not granted — the answer is a port number the mesh holds in the
|
||||||
|
// clear, and the credential to use it is still the module's own to have.
|
||||||
|
//
|
||||||
|
// **The answer may be empty, and that is the one place a placeholder answers with nothing.** The
|
||||||
|
// store and the broker are raised at genesis, before the mesh knows them as modules; a mesh raised
|
||||||
|
// on the catalogue's own ports never gives them a setting at all. In both, the port genesis wrote
|
||||||
|
// into the connection string is the right one, and the mesh has nothing to add. An empty answer
|
||||||
|
// says exactly that, and what reads it — the control plane's `_PORT` twin — treats an empty value
|
||||||
|
// as no value. Answering with the software's own port instead would override what genesis wrote
|
||||||
|
// with a number the mesh never checked, on the one machine where that is a headless mesh.
|
||||||
|
|
||||||
|
// ofSeat is where a module asks about a seat: ${seat:<seat>:<the port its holder's software uses>}.
|
||||||
|
var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
|
||||||
|
|
||||||
|
// 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 environment. The same two places
|
||||||
|
// portInto fills, for the same reason: they are where a process reads a number from.
|
||||||
|
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) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
filled, err := seatsFilledInto(content, fmt.Sprintf("%s has a file that", module), with)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resource["content"] = filled
|
||||||
|
|
||||||
|
case "container":
|
||||||
|
env, ok := resource["env"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
named := make([]string, 0, len(env))
|
||||||
|
for key := range env {
|
||||||
|
named = append(named, key)
|
||||||
|
}
|
||||||
|
sort.Strings(named)
|
||||||
|
|
||||||
|
// A fresh map, and only when something changes — this map is the catalogue's, shared by
|
||||||
|
// every node running the module (see portInto).
|
||||||
|
var filled map[string]any
|
||||||
|
for _, key := range named {
|
||||||
|
written, ok := env[key].(string)
|
||||||
|
if !ok || !ofSeat.MatchString(written) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := seatsFilledInto(written,
|
||||||
|
fmt.Sprintf("%s's container %s sets %s to something that",
|
||||||
|
module, resource["name"], key), with)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if filled == nil {
|
||||||
|
filled = map[string]any{}
|
||||||
|
for k, v := range env {
|
||||||
|
filled[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filled[key] = value
|
||||||
|
}
|
||||||
|
if filled != nil {
|
||||||
|
resource["env"] = filled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatsFilledInto answers every ${seat:…} in one written value.
|
||||||
|
//
|
||||||
|
// A port the seat's holder does not publish on this machine — or a seat nothing on it holds —
|
||||||
|
// answers with nothing, for the reason the package comment gives. A port that is not one is
|
||||||
|
// refused: it was written by a person and it is wrong.
|
||||||
|
func seatsFilledInto(written, where string, with Rendering) (string, error) {
|
||||||
|
for _, m := range ofSeat.FindAllStringSubmatch(written, -1) {
|
||||||
|
seat, port := m[1], m[2]
|
||||||
|
wanted, err := strconv.Atoi(port)
|
||||||
|
if err != nil || wanted < 1 || wanted > 65535 {
|
||||||
|
return "", fmt.Errorf("%s says ${seat:%s:%s}, and %s is not a port", where, seat, port, port)
|
||||||
|
}
|
||||||
|
answer := ""
|
||||||
|
if at, known := with.Seats[seat][wanted]; known {
|
||||||
|
answer = strconv.Itoa(at)
|
||||||
|
}
|
||||||
|
written = strings.ReplaceAll(written, m[0], answer)
|
||||||
|
}
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The control plane's own addresses follow the node's ports (novox/hq 04-ISSUES/102).
|
||||||
|
|
||||||
|
func TestASeatPlaceholderAnswersWhereThisMachinePutTheHolder(t *testing.T) {
|
||||||
|
control := map[string]any{
|
||||||
|
"type": "container", "id": "server", "name": "mesh-controller",
|
||||||
|
"env": map[string]any{
|
||||||
|
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
|
||||||
|
"MESH_BROKER_AMQP_PORT": "${seat:mesh-broker:5672}",
|
||||||
|
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
||||||
|
"MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
with := Rendering{Seats: map[string]map[int]int{
|
||||||
|
"mesh-store": {5432: 6852}, "mesh-broker": {5672: 5679, 5671: 5671},
|
||||||
|
}}
|
||||||
|
if err := seatInto(control, "mesh-controller", with); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := control["env"].(map[string]any)
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
"MESH_STORE_INVENTORY_PORT": "6852",
|
||||||
|
"MESH_BROKER_AMQP_PORT": "5679",
|
||||||
|
"MESH_BROKER_ADDRESS_PORT": "5671",
|
||||||
|
"MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory",
|
||||||
|
} {
|
||||||
|
if env[key] != want {
|
||||||
|
t.Errorf("%s = %v, want %q", key, env[key], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A seat nothing on this machine holds — or one whose holder the mesh has given no port — answers
|
||||||
|
// with nothing, so the port genesis wrote into the connection string stands. Not the software's
|
||||||
|
// own port: on a node given a port at genesis before the store's module exists, that would
|
||||||
|
// override the right number with the catalogue's.
|
||||||
|
func TestASeatTheMeshCannotPlaceAnswersWithNothing(t *testing.T) {
|
||||||
|
control := map[string]any{
|
||||||
|
"type": "container", "id": "server", "name": "mesh-controller",
|
||||||
|
"env": map[string]any{"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}"},
|
||||||
|
}
|
||||||
|
if err := seatInto(control, "mesh-controller", Rendering{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := control["env"].(map[string]any)["MESH_STORE_INVENTORY_PORT"]; got != "" {
|
||||||
|
t.Fatalf("with nothing known, the seat answered %q", got)
|
||||||
|
}
|
||||||
|
file := map[string]any{"type": "file", "content": "port=${seat:mesh-store:5432}\n"}
|
||||||
|
if err := seatInto(file, "x", Rendering{Seats: map[string]map[int]int{"mesh-store": {5433: 1}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := file["content"]; got != "port=\n" {
|
||||||
|
t.Fatalf("a port the holder does not publish answered %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASeatPlaceholderNamingNoPortIsRefused(t *testing.T) {
|
||||||
|
file := map[string]any{"type": "file", "content": "${seat:mesh-store:99999}"}
|
||||||
|
if err := seatInto(file, "x", Rendering{}); err == nil {
|
||||||
|
t.Fatal("99999 was accepted as a port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFillingASeatLeavesTheManifestAlone(t *testing.T) {
|
||||||
|
env := map[string]any{"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}"}
|
||||||
|
manifest := map[string]any{"type": "container", "id": "server", "env": env}
|
||||||
|
for _, at := range []int{6852, 5432} {
|
||||||
|
copied := map[string]any{}
|
||||||
|
for k, v := range manifest {
|
||||||
|
copied[k] = v
|
||||||
|
}
|
||||||
|
with := Rendering{Seats: map[string]map[int]int{"mesh-store": {5432: at}}}
|
||||||
|
if err := seatInto(copied, "mesh-controller", with); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env["MESH_STORE_INVENTORY_PORT"] != "${seat:mesh-store:5432}" {
|
||||||
|
t.Fatalf("the module's own manifest was edited: %v", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The control plane's own manifest, composed through the whole path.**
|
||||||
|
//
|
||||||
|
// The store was given 6852 and the broker's plain port 5679 (the control-node's migration, novox/hq
|
||||||
|
// 04-ISSUES/102). The control plane's own connections are sealed at genesis with the ports genesis
|
||||||
|
// wrote; what its container is told beside them is where this machine put the store and the
|
||||||
|
// broker now, read from the node's settings exactly as every consumer's binding is.
|
||||||
|
func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err := ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the control plane's own manifest does not parse:\n%v", err)
|
||||||
|
}
|
||||||
|
// The manifest itself names them now; withSeatPorts is a no-op on it, and this holds it so.
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] != "container" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
env, _ := r["env"].(map[string]any)
|
||||||
|
for key, want := range SeatPorts {
|
||||||
|
if env[key] != want {
|
||||||
|
t.Errorf("module.json says %s=%v, not %q", key, env[key], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m = withSeatPorts(m)
|
||||||
|
control, err := m.Resolve([]Built{{
|
||||||
|
Name: "server", Kind: ArtifactImage,
|
||||||
|
Reference: ArtifactStoreScheme + "mesh-controller/server@sha256:" + strings.Repeat("c", 64),
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := Resolution{Node: "anchor", Modules: []Manifest{control}}
|
||||||
|
needed := map[string]map[string]string{"mesh-controller": {}}
|
||||||
|
for name := range m.OwnSecrets {
|
||||||
|
needed["mesh-controller"][name] = "sealed-" + name
|
||||||
|
}
|
||||||
|
out, err := r.Declaration(Rendering{
|
||||||
|
Needed: needed,
|
||||||
|
ArtifactStore: "anchor.internal:5100",
|
||||||
|
Seats: map[string]map[int]int{
|
||||||
|
"mesh-store": {5432: 6852},
|
||||||
|
"mesh-broker": {5671: 5671, 5672: 5679, 15672: 15673},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the control plane does not compose: %v", err)
|
||||||
|
}
|
||||||
|
server := fileNamed(out, "mesh-controller.server")
|
||||||
|
if server == nil {
|
||||||
|
t.Fatalf("the control plane's container is not in the declaration: %v", out)
|
||||||
|
}
|
||||||
|
env, _ := server["env"].(map[string]any)
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
"MESH_STORE_INVENTORY_PORT": "6852",
|
||||||
|
"MESH_STORE_IDENTITY_PORT": "6852",
|
||||||
|
"MESH_STORE_LICENCES_PORT": "6852",
|
||||||
|
"MESH_BROKER_AMQP_PORT": "5679",
|
||||||
|
"MESH_BROKER_MANAGEMENT_PORT": "15673",
|
||||||
|
"MESH_BROKER_ADDRESS_PORT": "5671",
|
||||||
|
} {
|
||||||
|
if env[key] != want {
|
||||||
|
t.Errorf("the control plane is told %s=%v; the node put it on %s", key, env[key], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := server["image"]; got != "anchor.internal:5100/mesh-controller/server@sha256:"+strings.Repeat("c", 64) {
|
||||||
|
t.Errorf("the control plane's own image is %v, not routed through the store", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And on a mesh where the foundation is where genesis raised it, nothing is added.
|
||||||
|
out, err = r.Declaration(Rendering{Needed: needed, ArtifactStore: "anchor.internal:5100"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env, _ = fileNamed(out, "mesh-controller.server")["env"].(map[string]any)
|
||||||
|
if env["MESH_STORE_INVENTORY_PORT"] != "" || env["MESH_BROKER_AMQP_PORT"] != "" {
|
||||||
|
t.Errorf("with no settings, the control plane is told %v", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeatPorts is what the control plane's manifest says beside each sealed connection: the port
|
||||||
|
// this machine put the seat's holder at (novox/hq 04-ISSUES/102).
|
||||||
|
var SeatPorts = map[string]string{
|
||||||
|
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
|
||||||
|
"MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}",
|
||||||
|
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
|
||||||
|
"MESH_BROKER_AMQP_PORT": "${seat:mesh-broker:5672}",
|
||||||
|
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
|
||||||
|
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSeatPorts is the control plane's manifest with SeatPorts in its container's environment.
|
||||||
|
//
|
||||||
|
// **The manifest lands one commit after the code that fills it**, deliberately: a control plane
|
||||||
|
// still running the previous build passes `${seat:…}` through unfilled, and the manifest may only
|
||||||
|
// name the placeholder once every control plane that could compose it knows it. So the test does
|
||||||
|
// not depend on module.json carrying these yet, and is a no-op once it does.
|
||||||
|
func withSeatPorts(m Manifest) Manifest {
|
||||||
|
out := m
|
||||||
|
out.Resources = nil
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] != "container" {
|
||||||
|
out.Resources = append(out.Resources, r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
copied := map[string]any{}
|
||||||
|
for k, v := range r {
|
||||||
|
copied[k] = v
|
||||||
|
}
|
||||||
|
env := map[string]any{}
|
||||||
|
if had, ok := r["env"].(map[string]any); ok {
|
||||||
|
for k, v := range had {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range SeatPorts {
|
||||||
|
if _, said := env[k]; !said {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copied["env"] = env
|
||||||
|
out.Resources = append(out.Resources, copied)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package envfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The port a machine put something on, said beside the value that names it.
|
||||||
|
//
|
||||||
|
// **An address written down when a port was decided does not follow the port** (novox/hq
|
||||||
|
// 04-ISSUES/102). The control plane's own store and broker connections are written at genesis —
|
||||||
|
// full connection strings, password and all — and sealed, so the mesh cannot open them to move the
|
||||||
|
// port inside. When the node's settings move that port, every consumer's binding follows and the
|
||||||
|
// control plane's own connection does not: it goes on dialling the number genesis wrote, and the
|
||||||
|
// mesh is headless.
|
||||||
|
//
|
||||||
|
// So a setting has a third twin. `NAME_FILE` says where the value is; `NAME_PORT` says which port
|
||||||
|
// this machine put the thing at, composed into the control plane's environment from the node's
|
||||||
|
// settings exactly as a consumer's binding is. The value's own port is what stands when the twin
|
||||||
|
// says nothing — a mesh whose foundation is still where genesis raised it needs no override, and
|
||||||
|
// an empty answer is the mesh saying it has nothing to add, not the mesh saying zero.
|
||||||
|
//
|
||||||
|
// Only the port. The host inside the value is the machine's own loopback, or the broker's public
|
||||||
|
// name — a fact of the genesis, not of any node's settings — and the mesh has no better opinion
|
||||||
|
// of it. What moves under ADR 0100 is the port.
|
||||||
|
|
||||||
|
// PortVar is the twin saying which port this machine put the named thing at.
|
||||||
|
func PortVar(name string) string { return name + "_PORT" }
|
||||||
|
|
||||||
|
// Port is what NAME_PORT says, checked to be a port, or "" when it says nothing.
|
||||||
|
//
|
||||||
|
// Blank counts as unset, as it does for every other variable here: a container given an empty
|
||||||
|
// string was given nothing, and refusing it as ambiguous would turn an omission into a puzzle.
|
||||||
|
func Port(name string) (string, error) {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(PortVar(name)))
|
||||||
|
if raw == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if unfilled.MatchString(raw) {
|
||||||
|
// **A placeholder the mesh never filled, and this is the one place it must not be a
|
||||||
|
// fault.** The control plane composes its own declaration, so a manifest naming
|
||||||
|
// `${seat:…}` reaches a control plane one build older than the manifest — one that does
|
||||||
|
// not know the placeholder and passes it through as the value. Refusing it here would
|
||||||
|
// leave every command and the daemon unable to open a store: headless, on the machine
|
||||||
|
// that cannot be repaired through the mesh (novox/hq 04-ISSUES/102). So it is what an
|
||||||
|
// empty answer is — nothing said, the sealed value stands — and it is said aloud, because
|
||||||
|
// a manifest ahead of its binary is worth a line on stderr and not worth an outage.
|
||||||
|
fmt.Fprintf(os.Stderr, "%s is %q, a placeholder nothing filled in — ignored; the port in "+
|
||||||
|
"%s stands. The control plane composing this declaration is older than the manifest "+
|
||||||
|
"naming it: rebuild and push it\n", PortVar(name), raw, name)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n < 1 || n > 65535 {
|
||||||
|
return "", fmt.Errorf("%s is %q, which is not a port", PortVar(name), raw)
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placed is Value, with its port moved to where NAME_PORT says this machine put it.
|
||||||
|
func Placed(name string) (string, error) {
|
||||||
|
value, err := Value(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
port, err := Port(name)
|
||||||
|
if err != nil || port == "" {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
placed, err := WithPort(value, port)
|
||||||
|
if err != nil {
|
||||||
|
// Where it came from is said; what it says is not. The value is a connection string with
|
||||||
|
// a password in it, and every error here is written on the assumption it will be logged.
|
||||||
|
return "", fmt.Errorf("%s names a port to move %s to, and %s could not be read as "+
|
||||||
|
"something with a port in it: %w", PortVar(name), name, name, err)
|
||||||
|
}
|
||||||
|
return placed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// keywordPort is `port=…` in a `key=value` connection string.
|
||||||
|
var keywordPort = regexp.MustCompile(`(^|\s)port=\S*`)
|
||||||
|
|
||||||
|
// unfilled is a value that is still a placeholder — `${…}` — rather than something a person or the
|
||||||
|
// mesh wrote as a port.
|
||||||
|
var unfilled = regexp.MustCompile(`^\$\{[^}]*\}$`)
|
||||||
|
|
||||||
|
// WithPort is the value with its port replaced by `port`.
|
||||||
|
//
|
||||||
|
// Three shapes, because the control plane's settings come in three: a URL
|
||||||
|
// (`scheme://[user:password@]host[:port][/…]`), a bare `host[:port]` (the broker's address) and
|
||||||
|
// a `key=value` connection string (`host=… port=…`), which is what the store's driver accepts
|
||||||
|
// beside a URL. An IPv6 host stays in its brackets. Nothing here parses the URL properly — a
|
||||||
|
// password with a character a URL parser dislikes is still a working connection string, and
|
||||||
|
// refusing it would refuse a value that has been dialling fine since genesis.
|
||||||
|
func WithPort(value, port string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", fmt.Errorf("the value is empty")
|
||||||
|
}
|
||||||
|
if scheme, rest, isURL := strings.Cut(value, "://"); isURL {
|
||||||
|
// **The password may hold any of `/ ? # @`, so the host begins after the LAST `@`**, and
|
||||||
|
// the path only after that. Cutting at the first `/` would take a password's slash for the
|
||||||
|
// path's and put the new port on the user name — a connection string that dials the wrong
|
||||||
|
// host without a word. Genesis makes passwords that cannot do this; an accepted one can.
|
||||||
|
// The connection strings this reads — a store's, a broker's, a management API's — carry
|
||||||
|
// no `@` after their userinfo, which is what makes the last one the boundary.
|
||||||
|
userinfo, hostAndTail := "", rest
|
||||||
|
if at := strings.LastIndex(rest, "@"); at >= 0 {
|
||||||
|
userinfo, hostAndTail = rest[:at+1], rest[at+1:]
|
||||||
|
}
|
||||||
|
authority, tail := hostAndTail, ""
|
||||||
|
if end := strings.IndexAny(hostAndTail, "/?#"); end >= 0 {
|
||||||
|
authority, tail = hostAndTail[:end], hostAndTail[end:]
|
||||||
|
}
|
||||||
|
host, err := hostWithPort(authority, port)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return scheme + "://" + userinfo + host + tail, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(value, "=") && !strings.ContainsAny(value, "/") {
|
||||||
|
if keywordPort.MatchString(value) {
|
||||||
|
return keywordPort.ReplaceAllString(value, "${1}port="+port), nil
|
||||||
|
}
|
||||||
|
return value + " port=" + port, nil
|
||||||
|
}
|
||||||
|
return hostWithPort(value, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostWithPort is `host[:port]` with the port set, brackets kept around an IPv6 host.
|
||||||
|
func hostWithPort(authority, port string) (string, error) {
|
||||||
|
if authority == "" {
|
||||||
|
return "", fmt.Errorf("there is no host to put a port on")
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(authority)
|
||||||
|
if err != nil {
|
||||||
|
// No port yet. A bracketed IPv6 host without a port is a shape SplitHostPort refuses, and
|
||||||
|
// a bare one carries colons of its own — both are a host, not an error.
|
||||||
|
host = strings.TrimSuffix(strings.TrimPrefix(authority, "["), "]")
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, port), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package envfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The control plane's own connections follow the port the node moved (novox/hq 04-ISSUES/102).
|
||||||
|
|
||||||
|
func TestAPortTwinMovesTheValuesPort(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"postgres://mesh:s3cret@127.0.0.1:5432/inventory?sslmode=disable": "postgres://mesh:s3cret@127.0.0.1:6852/inventory?sslmode=disable",
|
||||||
|
"postgres://mesh:s3cret@127.0.0.1/inventory": "postgres://mesh:s3cret@127.0.0.1:6852/inventory",
|
||||||
|
"amqp://control:p%40ss@127.0.0.1:5672/": "amqp://control:p%40ss@127.0.0.1:6852/",
|
||||||
|
"amqp://control:p@ss:with@127.0.0.1:5672/": "amqp://control:p@ss:with@127.0.0.1:6852/",
|
||||||
|
"http://guest:guest@127.0.0.1:15672": "http://guest:guest@127.0.0.1:6852",
|
||||||
|
"postgres://mesh:a/b?c#d@e@127.0.0.1:5432/inventory": "postgres://mesh:a/b?c#d@e@127.0.0.1:6852/inventory",
|
||||||
|
"http://[::1]:15672/api": "http://[::1]:6852/api",
|
||||||
|
"broker.example:5671": "broker.example:6852",
|
||||||
|
"broker.example": "broker.example:6852",
|
||||||
|
"host=127.0.0.1 port=5432 dbname=inventory": "host=127.0.0.1 port=6852 dbname=inventory",
|
||||||
|
"host=127.0.0.1 dbname=inventory": "host=127.0.0.1 dbname=inventory port=6852",
|
||||||
|
}
|
||||||
|
for value, want := range cases {
|
||||||
|
got, err := WithPort(value, "6852")
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("WithPort(%q) = %q, %v; want %q", value, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlacedLeavesTheValueAloneWhenNothingSaysAPort(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "value")
|
||||||
|
if err := os.WriteFile(path, []byte("postgres://m:p@127.0.0.1:5432/inventory\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("MESH_P_FILE", path)
|
||||||
|
t.Setenv("MESH_P_PORT", "")
|
||||||
|
got, err := Placed("MESH_P")
|
||||||
|
if err != nil || got != "postgres://m:p@127.0.0.1:5432/inventory" {
|
||||||
|
t.Fatalf("got %q, %v", got, err)
|
||||||
|
}
|
||||||
|
t.Setenv("MESH_P_PORT", " 6852 ")
|
||||||
|
got, err = Placed("MESH_P")
|
||||||
|
if err != nil || got != "postgres://m:p@127.0.0.1:6852/inventory" {
|
||||||
|
t.Fatalf("got %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPortTwinThatIsNotAPortIsRefusedWithoutQuotingTheValue(t *testing.T) {
|
||||||
|
t.Setenv("MESH_Q", "postgres://m:hunter2@127.0.0.1:5432/inventory")
|
||||||
|
t.Setenv("MESH_Q_PORT", "many")
|
||||||
|
_, err := Placed("MESH_Q")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a port that is not a number was accepted")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "hunter2") {
|
||||||
|
t.Fatalf("the value was quoted back: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("MESH_Q", "")
|
||||||
|
t.Setenv("MESH_Q_PORT", "6852")
|
||||||
|
if _, err := Placed("MESH_Q"); err == nil {
|
||||||
|
t.Fatal("a port with no value to put it on was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A placeholder nothing filled is nothing said, not a fault: the control plane composing the
|
||||||
|
// declaration may be one build behind the manifest, and refusing would leave it headless.
|
||||||
|
func TestAnUnfilledPlaceholderIsNothingSaid(t *testing.T) {
|
||||||
|
t.Setenv("MESH_R", "postgres://m:p@127.0.0.1:5432/inventory")
|
||||||
|
t.Setenv("MESH_R_PORT", "${seat:mesh-store:5432}")
|
||||||
|
got, err := Placed("MESH_R")
|
||||||
|
if err != nil || got != "postgres://m:p@127.0.0.1:5432/inventory" {
|
||||||
|
t.Fatalf("an unfilled placeholder was not ignored: %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,8 +146,12 @@ func (s *Server) Answers(r Replayer) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Connect opens the control plane's own connection to the broker.
|
// Connect opens the control plane's own connection to the broker.
|
||||||
|
//
|
||||||
|
// On the port MESH_BROKER_AMQP_PORT names when the node's settings moved the broker (novox/hq
|
||||||
|
// 04-ISSUES/102) — the URL is genesis's, sealed, and its port is the one thing in it the node may
|
||||||
|
// have moved since.
|
||||||
func Connect(enroller Enroller, listener Listener) (*Server, error) {
|
func Connect(enroller Enroller, listener Listener) (*Server, error) {
|
||||||
url, err := envfile.Value(AMQPVar)
|
url, err := envfile.Placed(AMQPVar)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **The manifest's placeholder, unfilled, reaches a store reader and changes nothing.**
|
||||||
|
//
|
||||||
|
// The control plane composes its own declaration, so the manifest naming `${seat:…}` can be
|
||||||
|
// composed by a control plane one build older than it — one that passes the literal through as
|
||||||
|
// the value. That is the state of the live control-node between this manifest landing and its
|
||||||
|
// next build being pushed, and a reader that refused the literal would leave it headless
|
||||||
|
// (novox/hq 04-ISSUES/102, finding F1). So the reader is held to ignoring exactly what
|
||||||
|
// module.json says, not a placeholder shaped like it.
|
||||||
|
func TestTheManifestsOwnPlaceholderUnfilledLeavesTheStoreWhereTheFileSays(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Resources []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Env map[string]string `json:"env"`
|
||||||
|
} `json:"resources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var written string
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r.Type == "container" {
|
||||||
|
written = r.Env["MESH_STORE_INVENTORY_PORT"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if written == "" {
|
||||||
|
t.Fatal("module.json no longer names MESH_STORE_INVENTORY_PORT")
|
||||||
|
}
|
||||||
|
|
||||||
|
alone(t)
|
||||||
|
t.Setenv(Variable(example), dsn)
|
||||||
|
t.Setenv(PortVariable(example), written)
|
||||||
|
opened, err := Open(t.Context(), example)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the unfilled placeholder was refused, which is a headless control plane: %v", err)
|
||||||
|
}
|
||||||
|
defer opened.Close()
|
||||||
|
if got := opened.Pool().Config().ConnConfig.Port; got != 5432 {
|
||||||
|
t.Fatalf("the store is on %d; with the placeholder unfilled, the file's port stands", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -213,3 +213,55 @@ func mustNotLeak(t *testing.T, err error) {
|
|||||||
t.Fatalf("the password is in the error: %v", err)
|
t.Fatalf("the password is in the error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The node moved the store, and the control plane's own connection follows (novox/hq 04-ISSUES/102).
|
||||||
|
//
|
||||||
|
// The connection string is what genesis wrote, port and all; the port twin is what the node's
|
||||||
|
// settings say now. The pool's configuration is the one place both meet.
|
||||||
|
func TestThePortTwinMovesTheStoresPort(t *testing.T) {
|
||||||
|
alone(t)
|
||||||
|
t.Setenv(PortVariable(example), "")
|
||||||
|
path := filepath.Join(t.TempDir(), "inventory")
|
||||||
|
if err := os.WriteFile(path, []byte(dsn+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(FileVariable(example), path)
|
||||||
|
|
||||||
|
opened, err := Open(t.Context(), example)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := opened.Pool().Config().ConnConfig.Port; got != 5432 {
|
||||||
|
t.Fatalf("with nothing said, the store is on %d rather than what the file says", got)
|
||||||
|
}
|
||||||
|
opened.Close()
|
||||||
|
|
||||||
|
t.Setenv(PortVariable(example), "6852")
|
||||||
|
opened, err = Open(t.Context(), example)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a moved port was refused: %v", err)
|
||||||
|
}
|
||||||
|
defer opened.Close()
|
||||||
|
if got := opened.Pool().Config().ConnConfig.Port; got != 6852 {
|
||||||
|
t.Fatalf("the store is on %d, and the node put it on 6852", got)
|
||||||
|
}
|
||||||
|
if got := opened.Pool().Config().ConnConfig.Password; got != "s3cret-in-here" {
|
||||||
|
t.Fatalf("moving the port changed the password to %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPortTwinThatIsNotAPortNamesItselfAndNotTheSecret(t *testing.T) {
|
||||||
|
alone(t)
|
||||||
|
t.Setenv(Variable(example), dsn)
|
||||||
|
t.Setenv(PortVariable(example), "six")
|
||||||
|
_, err := Open(t.Context(), example)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a port that is not a number was accepted")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), PortVariable(example)) {
|
||||||
|
t.Errorf("the error does not name the variable: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "s3cret") {
|
||||||
|
t.Errorf("the error quotes the password: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+37
-1
@@ -25,6 +25,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/envfile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// contextName is what a context may be called.
|
// contextName is what a context may be called.
|
||||||
@@ -67,6 +69,17 @@ func Variable(context string) string {
|
|||||||
// raises the first one.
|
// raises the first one.
|
||||||
func FileVariable(context string) string { return Variable(context) + "_FILE" }
|
func FileVariable(context string) string { return Variable(context) + "_FILE" }
|
||||||
|
|
||||||
|
// PortVariable is the environment variable naming the PORT this machine put the store at — the
|
||||||
|
// third twin, beside the value and the file.
|
||||||
|
//
|
||||||
|
// **The connection string is sealed and the port inside it is genesis's** (novox/hq 04-ISSUES/102).
|
||||||
|
// The control plane cannot open its own store secret to move the port, and a node's settings can
|
||||||
|
// move the store (ADR 0100: the foundation's ports are the node's). Every consumer's binding
|
||||||
|
// followed that setting; the control plane's own connection did not, and the mesh was headless. So
|
||||||
|
// the port is composed into the control plane's environment from the node's settings, exactly as a
|
||||||
|
// consumer's binding is, and the connection string's own port stands only when this says nothing.
|
||||||
|
func PortVariable(context string) string { return envfile.PortVar(Variable(context)) }
|
||||||
|
|
||||||
// Database is what a context's database is called.
|
// Database is what a context's database is called.
|
||||||
//
|
//
|
||||||
// Named after the context, so that a person looking at a PostgreSQL server can see which
|
// Named after the context, so that a person looking at a PostgreSQL server can see which
|
||||||
@@ -85,7 +98,7 @@ func Open(ctx context.Context, name string) (*Store, error) {
|
|||||||
"name, so it must be lower-case letters and digits, starting with a letter", name)
|
"name, so it must be lower-case letters and digits, starting with a letter", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
dsn, from, err := settingsFor(name)
|
dsn, from, err := placed(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -169,6 +182,29 @@ func settingsFor(name string) (dsn, from string, err error) {
|
|||||||
return direct, Variable(name), nil
|
return direct, Variable(name), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// placed is a context's connection string with its port moved to where this machine put the
|
||||||
|
// store, when the node's settings say so (PortVariable), and as it was otherwise.
|
||||||
|
func placed(name string) (dsn, from string, err error) {
|
||||||
|
dsn, from, err = settingsFor(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
port, err := envfile.Port(Variable(name))
|
||||||
|
if err != nil || port == "" {
|
||||||
|
return dsn, from, err
|
||||||
|
}
|
||||||
|
moved, err := envfile.WithPort(dsn, port)
|
||||||
|
if err != nil {
|
||||||
|
// The variable and the port are named; the connection string is not, for the same reason
|
||||||
|
// as everywhere else in this file.
|
||||||
|
return "", "", fmt.Errorf(
|
||||||
|
"%s says this machine put the %s store on port %s, and the connection settings in %s "+
|
||||||
|
"could not be read as something with a port in them: %w",
|
||||||
|
PortVariable(name), name, port, from, err)
|
||||||
|
}
|
||||||
|
return moved, from + ", on port " + port + " as " + PortVariable(name) + " says", nil
|
||||||
|
}
|
||||||
|
|
||||||
// Context is which context this store belongs to.
|
// Context is which context this store belongs to.
|
||||||
func (s *Store) Context() string { return s.context }
|
func (s *Store) Context() string { return s.context }
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -42,7 +42,13 @@
|
|||||||
"MESH_STORE_LICENCES_FILE": "/run/secrets/licences",
|
"MESH_STORE_LICENCES_FILE": "/run/secrets/licences",
|
||||||
"MESH_BROKER_AMQP_FILE": "/run/secrets/broker",
|
"MESH_BROKER_AMQP_FILE": "/run/secrets/broker",
|
||||||
"MESH_BROKER_MANAGEMENT_FILE": "/run/secrets/broker-management",
|
"MESH_BROKER_MANAGEMENT_FILE": "/run/secrets/broker-management",
|
||||||
"MESH_BROKER_ADDRESS_FILE": "/run/secrets/broker-address"
|
"MESH_BROKER_ADDRESS_FILE": "/run/secrets/broker-address",
|
||||||
|
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
|
||||||
|
"MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}",
|
||||||
|
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
|
||||||
|
"MESH_BROKER_AMQP_PORT": "${seat:mesh-broker:5672}",
|
||||||
|
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
|
||||||
|
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}"
|
||||||
},
|
},
|
||||||
"volumes": [
|
"volumes": [
|
||||||
"mesh-broker-tls:/broker-tls:ro",
|
"mesh-broker-tls:/broker-tls:ro",
|
||||||
|
|||||||
Reference in New Issue
Block a user