Two more of one fault, and the fault is the same as 04-ISSUES/038: the same-node path diverging from the cross-node one. The mesh works out what a provider on ANOTHER machine serves by walking that node — reading its manifest with that machine's port assignments, then settling the result with that node's settings layers — before offering it to a consumer. A provider on the consumer's OWN machine never passes through that walk, so every step of it had to be repeated in resolve.go's servedHere and declaration.go's here(). 038 repeated the port. Nothing repeated the settling. So a served value the operator supplied reached a co-located consumer as the manifest's empty default. On the ADR 0056 anchor that value is an internal CA's root: step-ca and route-proxy on one node, route-proxy's binding carrying root: "", an empty CA bundle written, a silent fall back to the system trust store, and issuance stopping with nothing saying why. The same step-ca on another node would have worked. The second is the mirror direction. gitea declares a bare container port 3000 and the machine publishes it as 20000:3000, but gitea's route CONTRIBUTION still said 3000 — so the proxy beside it dialled a port nothing listens on and answered 502. 038 fixed what a consumer is TOLD about a provider; this is what a workload TELLS a provider about itself. The redirect uses the CONTRIBUTING module's assignment, because the port is the workload's, not the proxy's; a contribution carried here from another machine is left exactly as it is, its port being that machine's to assign. Both are settled in Declaration, which is the first moment the machine's ports and the provider's settings both exist. That also removes an order dependence: the resolver built its same-node needs mid-walk, from whichever modules had been chosen by the time the requirement came up and in whatever order a map iterated, so what a co-located binding carried depended on the order somebody happened to assign things in. Re-deriving from the finished closure does not. servedHere keeps its job — deciding whether a same-node provider serves anything at all, which is what makes the need exist — and now says that its values are provisional. novox/hq ADR 0056 Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
273 lines
10 KiB
Go
273 lines
10 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A provider and its consumer on ONE machine, which is what ADR 0056's anchor is.
|
|
//
|
|
// **Every fault in this file is the same shape: the same-node path diverging from the cross-node
|
|
// one.** A provider on another machine is walked by the control plane — its served facts are
|
|
// re-derived from its manifest with that machine's port assignments and settled with that node's
|
|
// settings layers — and only then handed to the consumer. A provider on the consumer's OWN machine
|
|
// never passes through that walk, so every step of it had to be repeated here, and each step that
|
|
// was not is a promise the co-located arrangement quietly breaks.
|
|
//
|
|
// 04-ISSUES/038 was the first of them (the port). These are the rest.
|
|
|
|
// A root certificate, in the shape a certificate authority serves one.
|
|
const servedRoot = `-----BEGIN CERTIFICATE-----
|
|
MIIBeDCCAR2gAwIBAgIQfake0000000000000000000000
|
|
-----END CERTIFICATE-----
|
|
`
|
|
|
|
// stepCA is an internal ACME authority: it answers `acme-ca` from anywhere in the mesh, and what a
|
|
// consumer must know is the directory URL and the root to trust. **The root is not knowable when
|
|
// the module is written** — it exists only once the CA has been initialised — so the manifest
|
|
// declares the key and the operator supplies the value as a setting, which is exactly the shape the
|
|
// cross-node path settles and the same-node path did not.
|
|
func stepCA() Manifest {
|
|
return Manifest{
|
|
Module: "step-ca", Version: "1",
|
|
Provides: FromAnywhere("acme-ca"),
|
|
Listens: []Listening{{Port: 9000, Protocol: "tcp", From: FromMesh}},
|
|
Serves: map[string]map[string]any{"acme-ca": {
|
|
"directory": "https://anchor.internal/acme/acme/directory",
|
|
// Declared empty: a manifest is the same on every mesh, and this mesh's root is not.
|
|
"root": "",
|
|
}},
|
|
Resources: []map[string]any{{
|
|
"id": "server", "type": "container", "name": "step-ca", "ports": []any{"9000"},
|
|
}},
|
|
}
|
|
}
|
|
|
|
// routeProxy issues from that authority, so it must be told the root to verify it with.
|
|
func routeProxy() Manifest {
|
|
return Manifest{
|
|
Module: "route-proxy", Version: "1",
|
|
Requires: []string{"acme-ca"},
|
|
Provides: FromAnywhere("route"),
|
|
Binds: map[string]string{"acme-ca": "/var/lib/route-proxy/acme-ca.json"},
|
|
Receives: map[string]string{"route": "/var/lib/route-proxy/routes.json"},
|
|
Resources: []map[string]any{{
|
|
"id": "bundle", "type": "file", "path": "/var/lib/route-proxy/ca.pem", "mode": "0644",
|
|
"content": "${bound:acme-ca:root}",
|
|
}},
|
|
}
|
|
}
|
|
|
|
// A co-located provider's served VALUES reach its consumer, not just its served keys.
|
|
//
|
|
// The mesh walks a provider on ANOTHER machine and settles what it serves with that node's settings
|
|
// layers before offering it (cmd/mesh-control plan.go, theRestOfTheMesh). A provider on the
|
|
// consumer's own machine was never settled at all: resolve.go's servedHere and declaration.go's
|
|
// here() both read the manifest and stop there. So a served value the operator supplied — the one
|
|
// kind of value a manifest cannot carry, because it is different on every mesh — arrived as the
|
|
// manifest's empty default.
|
|
//
|
|
// The consequence is silent and total: route-proxy wrote an empty CA bundle, fell back to the
|
|
// system trust store, could not verify the internal authority, and no certificate was ever issued.
|
|
func TestACoLocatedProvidersServedValuesReachItsConsumer(t *testing.T) {
|
|
r, err := Resolve(shelf(stepCA(), routeProxy()),
|
|
[]string{"step-ca", "route-proxy"}, reachable(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// What the operator set on the provider, on this node — the CA's root, which only exists once
|
|
// the CA has been initialised.
|
|
out, err := r.Declaration(Rendering{Settings: SettingsBy{
|
|
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
bundle := fileNamed(out, "route-proxy.bundle")
|
|
if bundle == nil {
|
|
t.Fatalf("the consumer was given no bundle at all: %v", out)
|
|
}
|
|
if got := bundle["content"]; got != servedRoot {
|
|
t.Errorf("the co-located consumer was not told the root it must trust:\n got %q\nwant %q",
|
|
got, servedRoot)
|
|
}
|
|
}
|
|
|
|
// And the binding file says the same thing, for a consumer that reads the binding rather than a
|
|
// substituted placeholder. The two are one fact and must not be able to disagree.
|
|
func TestACoLocatedConsumersBindingCarriesTheSettledValues(t *testing.T) {
|
|
r, err := Resolve(shelf(stepCA(), routeProxy()),
|
|
[]string{"step-ca", "route-proxy"}, reachable(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Declaration(Rendering{
|
|
Settings: SettingsBy{
|
|
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
|
|
},
|
|
// And the machine moved the provider's port while it was at it, so both halves of the
|
|
// cross-node derivation are exercised at once.
|
|
Ports: map[string]map[int]int{"step-ca": {9000: 19000}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
binding := fileNamed(out, "route-proxy.bound-acme-ca")
|
|
if binding == nil {
|
|
t.Fatalf("the consumer was given no binding at all: %v", out)
|
|
}
|
|
var said struct {
|
|
Serves map[string]any `json:"serves"`
|
|
}
|
|
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if said.Serves["root"] != servedRoot {
|
|
t.Errorf("the binding does not carry the settled root: %q", said.Serves["root"])
|
|
}
|
|
if port, _ := asPort(said.Serves["port"]); port != 19000 {
|
|
t.Errorf("the binding does not carry the port the machine publishes: %v", said.Serves["port"])
|
|
}
|
|
}
|
|
|
|
// A provider PULLED IN rather than assigned is settled the same way.
|
|
//
|
|
// The resolver's same-node need is built during the walk, from whichever modules had been chosen by
|
|
// the time the requirement came up — so which path a co-located binding took depended on the order
|
|
// a person happened to assign things in. Settling after the closure is known removes that: both
|
|
// orders now produce the same file.
|
|
func TestACoLocatedProviderIsSettledWhicheverWayItWasPulledIn(t *testing.T) {
|
|
for _, assigned := range [][]string{
|
|
{"step-ca", "route-proxy"},
|
|
{"route-proxy", "step-ca"},
|
|
} {
|
|
r, err := Resolve(shelf(stepCA(), routeProxy()), assigned, reachable(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Declaration(Rendering{Settings: SettingsBy{
|
|
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bundle := fileNamed(out, "route-proxy.bundle")
|
|
if bundle == nil || bundle["content"] != servedRoot {
|
|
t.Errorf("assigned %v: the consumer was not told the root", assigned)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A same-node contribution names the port the MACHINE publishes, not the one the module declared.
|
|
//
|
|
// 04-ISSUES/038 fixed this for what a consumer is TOLD about a provider. This is the other
|
|
// direction: what a workload TELLS the provider about itself. gitea declares a bare container port
|
|
// 3000, the mesh publishes it as 20000:3000 — and gitea's route contribution still said 3000, so
|
|
// the proxy beside it dialled a port nothing listens on and answered 502 for every request.
|
|
//
|
|
// The redirect uses the CONTRIBUTING module's assignment: the port belongs to the workload, and
|
|
// using the provider's map would move it to wherever the proxy happens to be published.
|
|
func TestASameNodeContributionNamesThePortTheMachinePublishes(t *testing.T) {
|
|
gitea := Manifest{
|
|
Module: "gitea", Version: "1",
|
|
Listens: []Listening{{Port: 3000, Protocol: "tcp", From: FromMesh}},
|
|
Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}},
|
|
Resources: []map[string]any{{
|
|
"id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"},
|
|
}},
|
|
}
|
|
r, err := Resolve(shelf(gitea, routeProxy(), stepCA()),
|
|
[]string{"gitea", "route-proxy", "step-ca"}, reachable(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Declaration(Rendering{
|
|
// The machine put gitea's 3000 on 20000, and published it that way.
|
|
Ports: map[string]map[int]int{"gitea": {3000: 20000}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Where the workload is actually published.
|
|
server := fileNamed(out, "gitea.server")
|
|
if published := strings.Join(asStrings(server["ports"]), ","); published != "20000:3000" {
|
|
t.Fatalf("the workload was not published on the assigned port: %v", server["ports"])
|
|
}
|
|
|
|
// What the proxy beside it was told to dial: the SAME port.
|
|
routes := fileNamed(out, "route-proxy.received-route")
|
|
if routes == nil {
|
|
t.Fatalf("the proxy was given no routes file at all: %v", out)
|
|
}
|
|
var given struct {
|
|
Given []Contribution `json:"given"`
|
|
}
|
|
if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(given.Given) != 1 {
|
|
t.Fatalf("expected one route, got %v", given.Given)
|
|
}
|
|
port, ok := asPort(given.Given[0].Values["port"])
|
|
if !ok || port != 20000 {
|
|
t.Errorf("the proxy was told to dial a port nothing listens on: %v",
|
|
given.Given[0].Values["port"])
|
|
}
|
|
}
|
|
|
|
// A contribution from ANOTHER machine is left exactly as it was.
|
|
//
|
|
// Its port belongs to that machine's assignment, which this node's map knows nothing about —
|
|
// applying this node's map to it would move a remote workload's port to wherever a local module of
|
|
// the same name happens to be published, which is worse than the fault being fixed.
|
|
func TestAContributionFromAnotherMachineKeepsItsOwnPort(t *testing.T) {
|
|
r, err := Resolve(shelf(routeProxy(), stepCA()),
|
|
[]string{"route-proxy", "step-ca"}, reachable(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Declaration(Rendering{
|
|
// A same-named module on this machine, moved. The grant below is a laptop's, and must not
|
|
// be dragged along with it.
|
|
Ports: map[string]map[int]int{"gitea": {3000: 20000}},
|
|
Grants: []Grant{{
|
|
Provision: "route", Consumer: "laptop", From: "gitea", At: "laptop.internal",
|
|
Values: map[string]any{"name": "git.example", "port": 3000},
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
routes := fileNamed(out, "route-proxy.received-route")
|
|
var given struct {
|
|
Given []Contribution `json:"given"`
|
|
}
|
|
if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(given.Given) != 1 {
|
|
t.Fatalf("expected one route, got %v", given.Given)
|
|
}
|
|
if port, _ := asPort(given.Given[0].Values["port"]); port != 3000 {
|
|
t.Errorf("another machine's contribution was rewritten with this machine's ports: %v",
|
|
given.Given[0].Values["port"])
|
|
}
|
|
}
|
|
|
|
func asStrings(v any) []string {
|
|
listed, ok := v.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(listed))
|
|
for _, one := range listed {
|
|
out = append(out, strings.TrimSpace(plainly(one)))
|
|
}
|
|
return out
|
|
}
|