The forward chain blocked everything passing through the machine and then allowed the machine's own containers back by naming their address ranges: 172.16.0.0/12 and 192.168.128.0/17 fixed here, the rest recorded per machine by 0043. Every way of keeping that list correct fails — a constant describes one machine, a recorded range goes stale in silence and cannot tell a network the mesh made from one a predecessor left behind, and generating it from the modules would put half the rule set on the machine. The mesh has no position on a container reaching outward: that is not a port opened to anybody. So both chains are written around the links traffic arrives on. What did not arrive from outside is accepted in one line; what did meets the declared rules. The tunnel is named beside the outward links rather than treated as inside, or a port nothing declares would be reachable from every machine in the mesh. A machine that has not reported an outward link is sent no filter and keeps the one it has, refused where a person reads it rather than as a rule set that will not load. Removes the two constants, `node networks`, and the column behind it. novox/hq ADR 0140, superseding 0137 and 0139.
684 lines
29 KiB
Go
684 lines
29 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// novox/hq ADR 0100: on an adopted node the found firewall stays in force. The mesh declares
|
|
// openings where it would have loaded a filter, and guards its own ports in a table that only
|
|
// refuses.
|
|
|
|
// hub is the private network's generator on the hub: it opens the hub's port from anywhere.
|
|
type hub struct{}
|
|
|
|
func (hub) Resources(string) ([]map[string]any, bool, error) {
|
|
return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf",
|
|
"content": "[Interface]\n"}}, true, nil
|
|
}
|
|
func (hub) Listens(string) ([]Listening, error) {
|
|
return []Listening{{Port: 51820, Protocol: "udp", From: FromEverywhere}}, nil
|
|
}
|
|
|
|
// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
|
|
// a served module and the filter module.
|
|
func anAdoptedAnchor() Resolution {
|
|
return Resolution{Node: "anchor", Modules: []Manifest{
|
|
{Module: "network", Computed: "overlay"},
|
|
{Module: "postgres", Guards: []int{5432},
|
|
Listens: []Listening{{Port: 5432, From: FromMesh}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
|
|
"ports": []any{"5432:5432"}}}},
|
|
{Module: "lavinmq", Guards: []int{15672},
|
|
Listens: []Listening{{Port: 5671, From: FromMesh}, {Port: 5672, From: FromMesh}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
|
|
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}},
|
|
{Module: "distribution",
|
|
Listens: []Listening{{Port: 5000, From: FromMesh}},
|
|
Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry",
|
|
"ports": []any{"5000"}}}},
|
|
{Module: "hello-web",
|
|
Listens: []Listening{{Port: 8080, From: FromEverywhere}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
|
|
"ports": []any{"8080"}}}},
|
|
{Module: "helper", Listens: []Listening{{Port: 9000, From: FromMachine}}},
|
|
{Module: "nftables", Filtering: &Filtering{Into: "/etc/nftables.conf"},
|
|
Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service",
|
|
"state": "running", "restart-on": []any{"filtering"}}}},
|
|
}}
|
|
}
|
|
|
|
func anchorRendering(adopted bool) Rendering {
|
|
return Rendering{
|
|
Generators: map[string]Generator{"overlay": hub{}},
|
|
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
|
|
Settings: SettingsBy{"distribution": {{From: "node anchor",
|
|
Values: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
|
Mesh: []string{"10.42.0.1"},
|
|
Foundation: []int{5671},
|
|
// What the machine reported faces outside, which every rule in the filter is written
|
|
// around (novox/hq ADR 0140).
|
|
OutwardLinks: []string{"eth0"},
|
|
TunnelInterface: "mesh0",
|
|
Adopted: adopted,
|
|
// Genesis takes the foundation's modules.
|
|
Taken: map[string]bool{"postgres": true, "lavinmq": true},
|
|
}
|
|
}
|
|
|
|
func byID(resources []map[string]any) map[string]map[string]any {
|
|
out := map[string]map[string]any{}
|
|
for _, r := range resources {
|
|
out[r["id"].(string)] = r
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) {
|
|
composed, err := anAdoptedAnchor().Compose(anchorRendering(true))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := byID(composed.Resources)
|
|
want := map[string]map[string]any{
|
|
// The hub's port, from anywhere, received.
|
|
"adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp",
|
|
"from": "everywhere", "path": "incoming"},
|
|
// The store's port from the private network only, and forwarded: a container publishes it.
|
|
"adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh",
|
|
"path": "forwarded", "to": 5432},
|
|
// The bus from anywhere: a node enrols over it before it has a private address.
|
|
"adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp",
|
|
"from": "everywhere", "path": "forwarded", "to": 5671},
|
|
"adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh",
|
|
"path": "forwarded", "to": 5672},
|
|
// The registry from anywhere, by its node's exposure setting.
|
|
"adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp",
|
|
"from": "everywhere", "path": "forwarded", "to": 5000},
|
|
// A published port names the machine port and the container port it is forwarded to.
|
|
"adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp",
|
|
"from": "everywhere", "path": "forwarded", "to": 8080},
|
|
}
|
|
for id, fields := range want {
|
|
opening, ok := got[id]
|
|
if !ok {
|
|
t.Errorf("no %s among %v", id, keys(got))
|
|
continue
|
|
}
|
|
if opening["type"] != "opening" {
|
|
t.Errorf("%s is a %v", id, opening["type"])
|
|
}
|
|
for k, v := range fields {
|
|
if opening[k] != v {
|
|
t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v)
|
|
}
|
|
}
|
|
}
|
|
for id := range got {
|
|
if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil {
|
|
t.Errorf("an opening nothing asked for: %s", id)
|
|
}
|
|
}
|
|
// A port for this machine only opens nothing, and the management port is not opened at all.
|
|
for id := range got {
|
|
if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") {
|
|
t.Errorf("%s is opened", id)
|
|
}
|
|
}
|
|
|
|
// And openings come first, in the order the machine applies them.
|
|
if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") {
|
|
t.Errorf("openings are not first: %v", composed.Resources[0]["id"])
|
|
}
|
|
}
|
|
|
|
func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) {
|
|
composed, err := anAdoptedAnchor().Compose(anchorRendering(true))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range composed.Resources {
|
|
if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") {
|
|
t.Fatalf("an adopted node is declared the filter module's %v", r["id"])
|
|
}
|
|
if content, _ := r["content"].(string); strings.Contains(content, "policy drop") {
|
|
t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"])
|
|
}
|
|
// Nothing but refusals: the only accept in the guard is its policy.
|
|
if content, _ := r["content"].(string); r["id"] == GuardID() &&
|
|
strings.Count(content, "accept") != 1 {
|
|
t.Fatalf("the guard holds an accept:\n%s", content)
|
|
}
|
|
}
|
|
got := byID(composed.Resources)
|
|
guard := got[GuardID()]
|
|
if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil {
|
|
t.Fatalf("no guard: %v", keys(got))
|
|
}
|
|
if guard["content"] != AsGuard([]int{5432, 5672, 15672}) {
|
|
t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s",
|
|
guard["content"])
|
|
}
|
|
// The tool that loads it comes first, and the table after it: a node joining adopted has no
|
|
// filter module and may have no nft.
|
|
pkg, table := -1, -1
|
|
for i, r := range composed.Resources {
|
|
switch r["id"] {
|
|
case GuardPackageID():
|
|
pkg = i
|
|
if r["type"] != "package" || r["package"] != "nftables" {
|
|
t.Fatalf("the guard's package is %v", r)
|
|
}
|
|
case GuardID():
|
|
table = i
|
|
}
|
|
}
|
|
if pkg < 0 || pkg > table {
|
|
t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table)
|
|
}
|
|
// A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the
|
|
// table and leave the ports unguarded until it is loaded again. Only a changed unit restarts.
|
|
if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) ||
|
|
!reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) {
|
|
t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v",
|
|
got[GuardRunningID()])
|
|
}
|
|
// Nothing of the mesh's own is anybody's to hold.
|
|
for id, module := range composed.Owner {
|
|
if strings.HasPrefix(id, AdoptionPrefix) {
|
|
t.Fatalf("%s is owned by %s", id, module)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) {
|
|
composed, err := anAdoptedAnchor().Compose(anchorRendering(false))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := byID(composed.Resources)
|
|
if got["nftables.filtering"] == nil || got["nftables.load"] == nil {
|
|
t.Fatalf("a converged node lost its filter: %v", keys(got))
|
|
}
|
|
for id := range got {
|
|
if strings.HasPrefix(id, AdoptionPrefix) {
|
|
t.Fatalf("a converged node is declared %s", id)
|
|
}
|
|
}
|
|
plain, err := anAdoptedAnchor().Declaration(anchorRendering(false))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, _ := json.Marshal(plain)
|
|
b, _ := json.Marshal(composed.Resources)
|
|
if string(a) != string(b) {
|
|
t.Fatal("Compose and Declaration disagree on a converged node")
|
|
}
|
|
}
|
|
|
|
// The table the installer raises and the controller declares, character for character.
|
|
func TestTheGuardIsExactlyThisTable(t *testing.T) {
|
|
const golden = `table inet mesh_guard {}
|
|
delete table inet mesh_guard
|
|
table inet mesh_guard {
|
|
chain prerouting {
|
|
type filter hook prerouting priority raw; policy accept;
|
|
fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop
|
|
}
|
|
}
|
|
`
|
|
if got := AsGuard([]int{15672, 5432}); got != golden {
|
|
t.Fatalf("the guard changed:\n%s", got)
|
|
}
|
|
const unit = `[Unit]
|
|
Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)
|
|
DefaultDependencies=no
|
|
Wants=network-pre.target
|
|
Before=network-pre.target shutdown.target
|
|
Conflicts=shutdown.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
RemainAfterExit=yes
|
|
ExecStart=nft -f /etc/mesh/guard.nft
|
|
ExecReload=nft -f /etc/mesh/guard.nft
|
|
ExecStop=nft delete table inet mesh_guard
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
if got := GuardUnitText(); got != unit {
|
|
t.Fatalf("the guard's unit changed:\n%s", got)
|
|
}
|
|
if GuardResources(nil) != nil {
|
|
t.Fatal("a guard with nothing to guard is an empty set nft refuses to load")
|
|
}
|
|
}
|
|
|
|
func TestAGuardedPortMustBeAPort(t *testing.T) {
|
|
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil {
|
|
t.Fatalf("guards is refused: %v", err)
|
|
}
|
|
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil {
|
|
t.Fatal("guarding port 0 was accepted")
|
|
}
|
|
}
|
|
|
|
func keys[V any](m map[string]V) []string {
|
|
return sortedKeys(m)
|
|
}
|
|
|
|
// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place
|
|
// that uses the port reads it from there: the container, the filter, the openings, the guard.
|
|
func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
|
|
given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}}
|
|
for _, adopted := range []bool{true, false} {
|
|
with := anchorRendering(adopted)
|
|
with.Given = given
|
|
with.Ports["postgres"] = map[int]int{5432: 5433}
|
|
composed, err := anAdoptedAnchor().Compose(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := byID(composed.Resources)
|
|
if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) {
|
|
t.Fatalf("the store's container publishes %v", ports)
|
|
}
|
|
if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports,
|
|
[]any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) {
|
|
t.Fatalf("the broker's container publishes %v", ports)
|
|
}
|
|
if !adopted {
|
|
filter, _ := got["nftables.filtering"]["content"].(string)
|
|
if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") {
|
|
t.Fatalf("the filter does not use the given port:\n%s", filter)
|
|
}
|
|
continue
|
|
}
|
|
if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 {
|
|
t.Fatalf("no opening for the given port: %v", keys(got))
|
|
}
|
|
if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) {
|
|
t.Fatalf("the guard does not guard the given ports:\n%s", guard)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAGivenPortIsTheNodesAndReachesSomething(t *testing.T) {
|
|
store := anAdoptedAnchor().Modules[1]
|
|
node := func(v any) []Layer {
|
|
return []Layer{{From: "anchor", Values: map[string]any{PortsSetting: v}}}
|
|
}
|
|
if got, err := GivenPorts(store, node(map[string]any{"5432": float64(5433)})); err != nil ||
|
|
got[5432] != 5433 {
|
|
t.Fatalf("a node's given port was not read: %v %v", got, err)
|
|
}
|
|
if _, err := GivenPorts(store, []Layer{{From: MeshWideLayer,
|
|
Values: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil {
|
|
t.Fatal("a port given for the whole mesh was accepted")
|
|
}
|
|
if _, err := GivenPorts(store, node(map[string]any{"6000": float64(6001)})); err == nil {
|
|
t.Fatal("a port the module neither listens on, publishes nor guards was given")
|
|
}
|
|
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(70000)})); err == nil {
|
|
t.Fatal("a machine port that is not a port was given")
|
|
}
|
|
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(22)})); err == nil {
|
|
t.Fatal("ssh's port was given")
|
|
}
|
|
broker := anAdoptedAnchor().Modules[2]
|
|
if _, err := GivenPorts(broker, node(map[string]any{"5671": float64(5700),
|
|
"5672": float64(5700)})); err == nil {
|
|
t.Fatal("one machine port was given for two of the module's ports")
|
|
}
|
|
if stray := UnusedSettings(store, node(map[string]any{"5432": float64(5433)})); len(stray) != 0 {
|
|
t.Fatalf("a given port is called stray: %v", stray)
|
|
}
|
|
}
|
|
|
|
// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken
|
|
// module publishes that the filter admits from the private network only, and the ports its
|
|
// manifest guards. A module assigned but not taken is not guarded: its port may still be the
|
|
// predecessor's.
|
|
func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) {
|
|
guardOf := func(with Rendering) string {
|
|
t.Helper()
|
|
composed, err := anAdoptedAnchor().Compose(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, _ := byID(composed.Resources)[GuardID()]["content"].(string)
|
|
return content
|
|
}
|
|
|
|
// The broker taken, the store not: the broker's plain port follows from its listens (from
|
|
// the mesh, published), its management port from its manifest; the store is not guarded, and
|
|
// neither is the bus, which the mesh needs from everywhere.
|
|
with := anchorRendering(true)
|
|
with.Taken = map[string]bool{"lavinmq": true}
|
|
if got := guardOf(with); got != AsGuard([]int{5672, 15672}) {
|
|
t.Fatalf("the guard is not the taken broker's ports:\n%s", got)
|
|
}
|
|
|
|
// A taken module publishing a port admitted from everywhere is not guarded; one admitted from
|
|
// the mesh is. The registry is exposed everywhere on this node, and hello-web listens from
|
|
// everywhere.
|
|
with.Taken = map[string]bool{"distribution": true, "hello-web": true}
|
|
if got := guardOf(with); got != "" {
|
|
t.Fatalf("a port admitted from everywhere is guarded:\n%s", got)
|
|
}
|
|
with.Settings = nil
|
|
if got := guardOf(with); got != AsGuard([]int{5000}) {
|
|
t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got)
|
|
}
|
|
|
|
// Nothing taken, nothing guarded — and no guard at all rather than an empty set.
|
|
with = anchorRendering(true)
|
|
with.Taken = nil
|
|
if got := guardOf(with); got != "" {
|
|
t.Fatalf("an untaken store is guarded:\n%s", got)
|
|
}
|
|
|
|
// A given port is followed: where the machine put it is what is refused.
|
|
with = anchorRendering(true)
|
|
with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}}
|
|
with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682}
|
|
if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) {
|
|
t.Fatalf("the guard does not follow the given ports:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// A mapping bound to loopback is not published to anything off the machine — in either address
|
|
// family — and an address's own colons never shift the ports.
|
|
func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) {
|
|
got := Published([]map[string]any{{"type": "container", "ports": []any{
|
|
"127.0.0.1:15672:15672", "[::1]:8080:80", "localhost:9090:90",
|
|
"[::]:8443:443", "0.0.0.0:5000:5000", "5353:53/udp"}}})
|
|
want := map[string]map[int]int{"tcp": {8443: 443, 5000: 5000}, "udp": {5353: 53}}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("published is %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine
|
|
// itself listens where its software was told to, so giving it a machine port is refused.
|
|
func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
|
|
onTheMachine := Manifest{Module: "daemon",
|
|
Listens: []Listening{{Port: 9000, From: FromMesh}}, Guards: []int{9000}}
|
|
layers := []Layer{{From: "node anchor",
|
|
Values: map[string]any{PortsSetting: map[string]any{"9000": float64(9100)}}}}
|
|
_, err := GivenPorts(onTheMachine, layers)
|
|
if err == nil || !strings.Contains(err.Error(), "does not publish") {
|
|
t.Fatalf("a port no container publishes was given: %v", err)
|
|
}
|
|
}
|
|
|
|
// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded.
|
|
// An opening from everywhere beside a guard dropping it is one statement refusing the other.
|
|
func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) {
|
|
with := anchorRendering(true)
|
|
with.Settings["postgres"] = []Layer{{From: "node anchor",
|
|
Values: map[string]any{ExposeSetting: map[string]any{"5432": FromEverywhere}}}}
|
|
composed, err := anAdoptedAnchor().Compose(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := byID(composed.Resources)
|
|
if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != OpeningFromEverywhere {
|
|
t.Fatalf("the store's port is not opened to everyone: %v", o)
|
|
}
|
|
if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) {
|
|
t.Fatalf("a port opened to everyone is still guarded:\n%s", guard)
|
|
}
|
|
}
|
|
|
|
// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon
|
|
// holds 22 — and says it listens on the machine side of that mapping, which is what anything
|
|
// reaching it dials.
|
|
func aForge() Manifest {
|
|
return Manifest{Module: "forge",
|
|
Provides: []Offer{{Name: "git-over-ssh", Scope: ScopeMesh}},
|
|
Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}},
|
|
Listens: []Listening{{Port: 3000, From: FromMesh}, {Port: 2222, From: FromMesh}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge",
|
|
"ports": []any{"3000", "2222:22"}}}}
|
|
}
|
|
|
|
// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way
|
|
// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module
|
|
// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written
|
|
// here because everything below — the filter, the openings, the guard, what a consumer is told —
|
|
// reads that map, and a given port that the lookup does not find moves the container's mapping
|
|
// and nothing else.
|
|
//
|
|
// It stands in for the plan only where the plan does not allocate: a port the manifest already
|
|
// placed, or one a node was given. For a short form with no given port the real plan asks the
|
|
// inventory for a machine port and may come back with one from the pool, which needs a store and
|
|
// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts
|
|
// this helper, not the mesh; keep the assertions to the ports under test.
|
|
func portsAsThePlanWould(m Manifest, given map[int]int) map[int]int {
|
|
out := map[int]int{}
|
|
for _, l := range m.Listens {
|
|
if at, is := given[l.Port]; is {
|
|
out[l.Port] = at
|
|
continue
|
|
}
|
|
at, _ := m.MachineSide(l.Port)
|
|
out[l.Port] = at
|
|
}
|
|
return out
|
|
}
|
|
|
|
// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module
|
|
// publishes, so a node may move it — and a module may name a mapping by either end.
|
|
func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
|
|
forge := aForge()
|
|
node := func(v any) []Layer {
|
|
return []Layer{{From: "node anchor", Values: map[string]any{PortsSetting: v}}}
|
|
}
|
|
|
|
// The machine side — the number this module says it listens on, and the one the predecessor
|
|
// had somewhere else. Answered under 2222, the end the module itself names, which is what
|
|
// every reader of this map asks for. One entry, not two: a second key for the same answer is
|
|
// an entry another mapping's reader could find instead.
|
|
given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)}))
|
|
if err != nil {
|
|
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
|
|
}
|
|
if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) {
|
|
t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want)
|
|
}
|
|
|
|
// The container's own port names the same mapping and means the same thing — and is filed
|
|
// under the same name, because the module's name for it has not changed.
|
|
if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil ||
|
|
!reflect.DeepEqual(inside, map[int]int{2222: 222}) {
|
|
t.Fatalf("the container's end of the mapping was given %v: %v", inside, err)
|
|
}
|
|
|
|
// A short form is published on the number it names, and is unchanged by any of this.
|
|
if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil ||
|
|
short[3000] != 2999 {
|
|
t.Fatalf("the short form was given %v: %v", short, err)
|
|
}
|
|
|
|
// A port no container publishes is still refused, in the same words.
|
|
if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil ||
|
|
!strings.Contains(err.Error(), "does not publish") {
|
|
t.Fatalf("a port the forge does not publish was given: %v", err)
|
|
}
|
|
|
|
// And the two ends of one mapping given two different numbers is one setting contradicting
|
|
// the other: the machine publishes it once.
|
|
if _, err := GivenPorts(forge, node(map[string]any{
|
|
"2222": float64(222), "22": float64(300)})); err == nil ||
|
|
!strings.Contains(err.Error(), "one mapping") {
|
|
t.Fatalf("the two ends of one mapping were given different ports: %v", err)
|
|
}
|
|
// Said at both ends with the same number, it is still said twice, and refused where every
|
|
// other repeated machine port is — as the inventory refuses it when the setting is stored,
|
|
// which is the layer that sees it first.
|
|
if _, err := GivenPorts(forge, node(map[string]any{
|
|
"2222": float64(222), "22": float64(222)})); err == nil ||
|
|
!strings.Contains(err.Error(), "to both its 22 and its 2222") {
|
|
t.Fatalf("one mapping given one machine port at both ends: %v", err)
|
|
}
|
|
// A number that names two different mappings names neither: which one to move is not said.
|
|
twice := aForge()
|
|
twice.Resources[0]["ports"] = []any{"22", "2222:22"}
|
|
if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil ||
|
|
!strings.Contains(err.Error(), "twice") {
|
|
t.Fatalf("a number naming two of the module's mappings was accepted: %v", err)
|
|
}
|
|
|
|
// And the same refusal when the number naming two mappings is not the one the setting used
|
|
// but the one the answer would be filed under. Here `80` is the module's own name for a
|
|
// mapping, and two mappings wear it; filing an answer there is one container's port standing
|
|
// where the other's is read, and both containers then publish it.
|
|
shared := Manifest{Module: "gallery",
|
|
Listens: []Listening{{Port: 80, From: FromMesh}},
|
|
Resources: []map[string]any{
|
|
{"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}},
|
|
{"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}}
|
|
if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil ||
|
|
!strings.Contains(err.Error(), "twice") {
|
|
t.Fatalf("two containers were put on one machine port: %v", err)
|
|
}
|
|
|
|
// A module whose mappings chain — one's machine side is another's container port — keeps them
|
|
// apart, because each is filed under the port the module names it by and neither name is
|
|
// shared. Given both, each moves on its own and neither overwrites the other.
|
|
chained := Manifest{Module: "chain",
|
|
Listens: []Listening{{Port: 80, From: FromMesh}, {Port: 9090, From: FromMesh}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain",
|
|
"ports": []any{"8080:80", "9090:8080"}}}}
|
|
both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)}))
|
|
if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) {
|
|
t.Fatalf("chained mappings were given %v: %v", both, err)
|
|
}
|
|
if moved := givenOuter("8080:80", both); moved != "1234:80" {
|
|
t.Fatalf("the first mapping moved to %q, and it was given 1234", moved)
|
|
}
|
|
if moved := givenOuter("9090:8080", both); moved != "5678:8080" {
|
|
t.Fatalf("the second mapping moved to %q, and it was given 5678", moved)
|
|
}
|
|
}
|
|
|
|
// And the number reaches everything derived from it. The fault this is written against moved the
|
|
// container's mapping alone: the filter opened the port the software had left, the adopted node's
|
|
// opening named it too, the guard refused it, and a consumer was sent to it.
|
|
func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) {
|
|
forge := aForge()
|
|
given, err := GivenPorts(forge, []Layer{{From: "node anchor",
|
|
Values: map[string]any{PortsSetting: map[string]any{"2222": float64(222)}}}})
|
|
if err != nil {
|
|
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
|
|
}
|
|
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
|
with := Rendering{
|
|
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
|
|
Given: map[string]map[int]int{"forge": given},
|
|
Mesh: []string{"10.77.0.1"},
|
|
Adopted: true,
|
|
OutwardLinks: []string{"eth0"},
|
|
TunnelInterface: "mesh0",
|
|
Taken: map[string]bool{"forge": true},
|
|
}
|
|
|
|
// What the runtime is handed: the machine's own port on the outside, the container's within.
|
|
composed, err := r.Compose(with)
|
|
if err != nil {
|
|
t.Fatalf("the forge does not compose: %v", err)
|
|
}
|
|
got := byID(composed.Resources)
|
|
if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) {
|
|
t.Fatalf("the forge's container publishes %v", ports)
|
|
}
|
|
|
|
// What the filter would open, were the node converged.
|
|
rules, err := r.Rules(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var opened []int
|
|
for _, rule := range rules {
|
|
opened = append(opened, rule.Port)
|
|
}
|
|
// Only the moved port is asserted: the forge's other port is a short form the real plan would
|
|
// allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and
|
|
// not the mesh's.
|
|
if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) {
|
|
t.Fatalf("the filter opens %v, not where the machine puts the forge", opened)
|
|
}
|
|
|
|
// And what it is declared instead, adopted: an opening on the machine's port, naming the
|
|
// container's port on the forwarded path — a published port is forwarded, never received.
|
|
opening := got[OpeningID("tcp", 222, PathForwarded)]
|
|
if opening == nil || opening["to"] != 22 || opening["from"] != OpeningFromMesh {
|
|
t.Fatalf("no opening for the port this node gave the forge: %v", keys(got))
|
|
}
|
|
for id := range got {
|
|
if strings.HasPrefix(id, "adoption.opening-tcp-2222-") {
|
|
t.Errorf("an opening for the port the forge was moved off: %s", id)
|
|
}
|
|
}
|
|
|
|
// The guard refuses it where the machine put it, and nothing where it used to be.
|
|
guard, _ := got[GuardID()]["content"].(string)
|
|
if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") {
|
|
t.Fatalf("the guard does not follow the given port:\n%s", guard)
|
|
}
|
|
|
|
// And a consumer is sent to the same number, which is read from what the module serves.
|
|
if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 {
|
|
t.Fatalf("a consumer is told the forge answers on %v", told)
|
|
}
|
|
}
|
|
|
|
// And the mapping itself moves under either name, because Rendering.Given is a map anybody
|
|
// composing a declaration hands in: keyed by the machine side, which is what a module declaring
|
|
// 2222 calls its port, only the outside moves and the container's own port stays as written.
|
|
func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) {
|
|
for _, c := range []struct {
|
|
written string
|
|
given map[int]int
|
|
want string
|
|
}{
|
|
{"2222:22", map[int]int{2222: 222}, "222:22"},
|
|
{"2222:22", map[int]int{22: 222}, "222:22"},
|
|
{"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"},
|
|
{"2222:22", map[int]int{3000: 2999}, "2222:22"},
|
|
{"2222:22", nil, "2222:22"},
|
|
} {
|
|
if got := givenOuter(c.written, c.given); got != c.want {
|
|
t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The same on a node that was given nothing, which is where the derivation was never at fault:
|
|
// a long-form mapping is published where the manifest put it, and an adopted node opens that port
|
|
// on the forwarded path like any other. What broke it was the number reaching only the mapping.
|
|
func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) {
|
|
forge := aForge()
|
|
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
|
composed, err := r.Compose(Rendering{
|
|
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
|
|
Mesh: []string{"10.77.0.1"},
|
|
Adopted: true,
|
|
OutwardLinks: []string{"eth0"},
|
|
TunnelInterface: "mesh0",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := byID(composed.Resources)
|
|
opening := got[OpeningID("tcp", 2222, PathForwarded)]
|
|
if opening == nil || opening["to"] != 22 {
|
|
t.Fatalf("no opening for the forge's published ssh port: %v", keys(got))
|
|
}
|
|
}
|